diff --git a/README.md b/README.md index 1bd10f7..592a64f 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,31 @@ Config lives at `.pentestcode/pentestcode.jsonc`: Providers: Anthropic, OpenAI, Google, Azure, AWS Bedrock, Ollama, Together, Groq, Fireworks, DeepSeek, Mistral, and more via [ai-sdk](https://github.com/vercel/ai). +### LLMTR (Turkey-hosted gateway) + +[LLMTR](https://llmtr.com) is a built-in, OpenAI-compatible AI gateway that fronts 200+ models +(global providers plus Turkey-hosted models with a data-residency guarantee) behind a single +endpoint. It ships as a first-class provider — no custom config needed. + +```bash +pentestcode auth login # pick "LLMTR", paste your API key +# or: +export LLMTR_API_KEY=sk-... # env var works too +``` + +```jsonc +{ + "provider": { + "llmtr": { + "model": "openai/gpt-5.5" // any model id from https://llmtr.com/v1/models + } + } +} +``` + +The model catalog is discovered live from `https://llmtr.com/v1/models` at startup (with a +curated offline fallback). Set `LLMTR_BASE_URL` to point at a self-hosted or staging gateway. + ## Contributing Bug reports from real usage are the most valuable thing you can send. Run PentestCode on a CTF box, an HTB machine, or an authorized pentest, and when something goes wrong — it loops, misses an obvious path, chokes on tool output, or wastes tokens — open an issue with: diff --git a/packages/core/src/plugin/provider.ts b/packages/core/src/plugin/provider.ts index 4d6f9ee..2aedd7a 100644 --- a/packages/core/src/plugin/provider.ts +++ b/packages/core/src/plugin/provider.ts @@ -15,6 +15,7 @@ import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/goog import { GroqPlugin } from "./provider/groq" import { KiloPlugin } from "./provider/kilo" import { LLMGatewayPlugin } from "./provider/llmgateway" +import { LLMTRPlugin } from "./provider/llmtr" import { MistralPlugin } from "./provider/mistral" import { NvidiaPlugin } from "./provider/nvidia" import { OpenAIPlugin } from "./provider/openai" @@ -51,6 +52,7 @@ export const ProviderPlugins: PluginInternal.Plugin process.env.LLMTR_BASE_URL?.trim() || DEFAULT_BASE_URL + +const price = (value: string | undefined) => { + const parsed = value === undefined ? Number.NaN : Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed * PRICE_SCALE : 0 +} + +const positiveInt = (value: number | undefined) => + typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0 + +const modalities = (input: readonly string[] | undefined) => { + const list = (input ?? []).filter((item) => KNOWN_MODALITIES.has(item)) + return list.length ? list : ["text"] +} + +/** Projects an LLMTR model description onto a catalog model draft. */ +function applyModel(draft: ModelV2Info, model: RemoteModel) { + const context = positiveInt(model.context_length ?? model.top_provider?.context_length) + const output = positiveInt(model.top_provider?.max_completion_tokens ?? model.context_length) || context + draft.name = model.name ?? model.id + draft.api = { id: model.id, type: "aisdk", package: PACKAGE } + draft.capabilities = { + tools: model.supported_parameters?.includes("tools") ?? false, + input: modalities(model.architecture?.input_modalities), + output: modalities(model.architecture?.output_modalities), + } + draft.cost = [ + { + input: price(model.pricing?.prompt), + output: price(model.pricing?.completion), + cache: { + read: price(model.pricing?.input_cache_read), + write: price(model.pricing?.input_cache_write), + }, + }, + ] + draft.time.released = model.created ? model.created * 1000 : 0 + draft.status = "active" + draft.enabled = true + draft.limit = { context, output } +} + +function mergeModels(seed: readonly RemoteModel[], fetched: readonly RemoteModel[]) { + const byId = new Map() + for (const model of seed) byId.set(model.id, model) + for (const model of fetched) byId.set(model.id, model) + return [...byId.values()] +} + +const fetchModels = () => + Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(`${baseURL()}/models`, { + headers: { Accept: "application/json", "User-Agent": `pentestcode/${InstallationVersion}` }, + signal, + }) + if (!response.ok) throw new Error(`LLMTR models request failed: ${response.status}`) + const body = (await response.json()) as { data?: RemoteModel[] } + return Array.isArray(body.data) ? body.data.filter((model) => typeof model?.id === "string") : [] + }, + catch: (cause) => cause, + }) + +export const LLMTRPlugin = define({ + id: PROVIDER_ID, + effect: Effect.fn(function* (ctx) { + let models: readonly RemoteModel[] = SEED_MODELS + + yield* ctx.integration.transform((draft) => { + draft.update(INTEGRATION_ID, (integration) => { + integration.name = DISPLAY_NAME + }) + draft.method.update({ integrationID: INTEGRATION_ID, method: { type: "key" } }) + draft.method.update({ integrationID: INTEGRATION_ID, method: { type: "env", names: [ENV_KEY] } }) + }) + + yield* ctx.catalog.transform((catalog) => { + catalog.provider.update(PROVIDER, (provider) => { + provider.name = DISPLAY_NAME + provider.integrationID = INTEGRATION_ID + provider.api = { type: "aisdk", package: PACKAGE, url: baseURL() } + provider.request.headers["HTTP-Referer"] ??= "https://github.com/s0ld13rr/pentestcode" + provider.request.headers["X-Title"] ??= "pentestcode" + }) + for (const model of models) { + catalog.model.update(PROVIDER, ModelV2.ID.make(model.id), (draft) => applyModel(draft, model)) + } + }) + + // Opt-out hook for hermetic tests / fully offline runs. + if (process.env.LLMTR_SKIP_REMOTE_MODELS === "1") return + + yield* Effect.forkScoped( + Effect.gen(function* () { + const fetched = yield* fetchModels().pipe(Effect.catch(() => Effect.succeed([] as RemoteModel[]))) + if (fetched.length === 0) return + models = mergeModels(SEED_MODELS, fetched) + yield* ctx.catalog.reload() + }), + ) + }), +}) diff --git a/packages/core/test/plugin/provider-llmtr.test.ts b/packages/core/test/plugin/provider-llmtr.test.ts new file mode 100644 index 0000000..83506ff --- /dev/null +++ b/packages/core/test/plugin/provider-llmtr.test.ts @@ -0,0 +1,95 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { Catalog } from "@pentestcode/core/catalog" +import { Integration } from "@pentestcode/core/integration" +import { ModelV2 } from "@pentestcode/core/model" +import { PluginV2 } from "@pentestcode/core/plugin" +import { PluginHost } from "@pentestcode/core/plugin/host" +import { ProviderPlugins } from "@pentestcode/core/plugin/provider" +import { LLMTRPlugin } from "@pentestcode/core/plugin/provider/llmtr" +import { ProviderV2 } from "@pentestcode/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +// Keep the plugin fully offline: assert only the synchronously registered +// provider/integration/seed models, never the forked live `/v1/models` fetch. +process.env.LLMTR_SKIP_REMOTE_MODELS = "1" + +const it = testEffect(PluginTestLayer) + +const addPlugin = Effect.fn(function* () { + const plugin = yield* PluginV2.Service + const host = yield* PluginHost.make(plugin) + yield* LLMTRPlugin.effect(host) +}) + +function required(value: T | undefined): T { + if (value === undefined) throw new Error("Expected value") + return value +} + +const LLMTR = ProviderV2.ID.make("llmtr") + +describe("LLMTRPlugin", () => { + it.effect("is registered in the provider plugin set", () => + Effect.sync(() => expect(ProviderPlugins.map((item) => item.id)).toContain(PluginV2.ID.make("llmtr"))), + ) + + it.effect("injects an OpenAI-compatible llmtr provider with branding headers", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* addPlugin() + const provider = required(yield* catalog.provider.get(LLMTR)) + expect(provider.name).toBe("LLMTR") + expect(provider.integrationID).toBe(Integration.ID.make("llmtr")) + expect(provider.api).toEqual({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://llmtr.com/v1", + }) + expect(provider.request.headers).toEqual({ + "HTTP-Referer": "https://github.com/s0ld13rr/pentestcode", + "X-Title": "pentestcode", + }) + }), + ) + + it.effect("registers an llmtr integration with key and env auth methods", () => + Effect.gen(function* () { + const integrations = yield* Integration.Service + yield* addPlugin() + const integration = required(yield* integrations.get(Integration.ID.make("llmtr"))) + expect(integration.name).toBe("LLMTR") + const types = integration.methods.map((method) => method.type).sort() + expect(types).toEqual(["env", "key"]) + const env = integration.methods.find((method) => method.type === "env") + expect(env && "names" in env ? env.names : []).toEqual(["LLMTR_API_KEY"]) + }), + ) + + it.effect("converts seed model pricing from per-token to per-1M and detects tool support", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* addPlugin() + + const gemma = required(yield* catalog.model.get(LLMTR, ModelV2.ID.make("llmtr/gemma-4"))) + expect(gemma.name).toBe("Gemma 4") + // provider url is inherited by the model at projection time + expect(gemma.api).toMatchObject({ + type: "aisdk", + package: "@ai-sdk/openai-compatible", + id: "llmtr/gemma-4", + url: "https://llmtr.com/v1", + }) + expect(gemma.capabilities.tools).toBe(true) + expect([...gemma.capabilities.input]).toEqual(["text", "image"]) + // "0.000002"/tok * 1e6 = 2.0/1M ; "0.000005"/tok -> 5.0 ; cache "0.0000005" -> 0.5 + expect(gemma.cost[0]).toEqual({ input: 2, output: 5, cache: { read: 0.5, write: 0 } }) + expect(gemma.limit).toEqual({ context: 131072, output: 131072 }) + + const asure = required(yield* catalog.model.get(LLMTR, ModelV2.ID.make("llmtr/trendyol-asure-12b"))) + expect(asure.capabilities.tools).toBe(false) + expect(asure.cost[0].input).toBeCloseTo(0.1, 10) + }), + ) +})