From 9cc0e2b9aa0b83d6cc399c42d3f92d45428cb2fd Mon Sep 17 00:00:00 2001 From: knowhycodata Date: Fri, 21 Aug 2026 18:10:00 +0300 Subject: [PATCH 1/4] feat(provider): add built-in LLMTR gateway provider LLMTR (https://llmtr.com) is a Turkey-hosted, OpenAI-compatible AI gateway fronting 200+ models (global providers + Turkey-hosted models with a data-residency guarantee) behind a single endpoint. - New `llmtr` provider plugin injects an `@ai-sdk/openai-compatible` provider into the catalog (url https://llmtr.com/v1), mirroring the openrouter/nvidia/ zenmux gateway pattern. Bearer key resolves from the `llmtr` integration. - Registers an `llmtr` integration with API-key + env (`LLMTR_API_KEY`) auth. - Discovers the model catalog live from the public `/v1/models` endpoint (OpenRouter-style schema), converting per-token pricing to per-1M cost and mapping modalities/tool support/limits. Curated offline seed of Turkey-hosted flagships keeps the provider usable without network. `LLMTR_BASE_URL` overrides the endpoint; `LLMTR_SKIP_REMOTE_MODELS=1` disables the live fetch. - Adds hermetic test coverage and a README section. Co-Authored-By: Claude Opus 4.8 --- README.md | 25 +++ packages/core/src/plugin/provider.ts | 2 + packages/core/src/plugin/provider/llmtr.ts | 208 ++++++++++++++++++ .../core/test/plugin/provider-llmtr.test.ts | 95 ++++++++ 4 files changed, 330 insertions(+) create mode 100644 packages/core/src/plugin/provider/llmtr.ts create mode 100644 packages/core/test/plugin/provider-llmtr.test.ts 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) + }), + ) +}) From 75ad401e61863fb41fd440918ca080a585ceef23 Mon Sep 17 00:00:00 2001 From: knowhycodata Date: Mon, 24 Aug 2026 11:12:07 +0300 Subject: [PATCH 2/4] fix(provider): map LLMTR reasoning_effort to on/off thinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLMTR models (gemma-4, qwen3, muse-glimmer) expose thinking as an on/off toggle (`reasoning: true`, or a `:think` model-id suffix), not OpenAI-style graded reasoning_effort. An active effort variant — or one carried over from another model — sent `reasoning_effort` and LLMTR rejected the whole request. Rewrite the outgoing request body for the LLMTR provider so any graded reasoning_effort becomes `reasoning: true` (an explicit off/none/minimal/ disabled keeps thinking off, and an existing reasoning flag is preserved), matching what LLMTR accepts. Add rewriteLLMTRReasoningBody plus unit tests. --- packages/opencode/src/provider/provider.ts | 40 +++++++++++++++++ .../test/provider/llmtr-reasoning.test.ts | 43 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 packages/opencode/test/provider/llmtr-reasoning.test.ts diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 709faff..11a288d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -34,6 +34,31 @@ import { ProviderError } from "./error" const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 +// LLMTR exposes model "thinking" as an on/off toggle (`reasoning: true`, or a +// `:think` model-id suffix), not OpenAI-style graded reasoning_effort. An active +// effort variant — or one carried over from another model — would otherwise send +// `reasoning_effort` and be rejected by LLMTR. Given a JSON request-body string, +// this returns an equivalent body with any `reasoning_effort` rewritten into +// LLMTR's on/off form (a graded effort maps to `reasoning: true`; an explicit +// off/none/minimal/disabled leaves thinking off). The original string is returned +// unchanged when it is not JSON or carries no `reasoning_effort`. Exported for tests. +export function rewriteLLMTRReasoningBody(bodyText: string): string { + let body: Record + try { + body = JSON.parse(bodyText) + } catch { + return bodyText + } + if (!isRecord(body) || !("reasoning_effort" in body)) return bodyText + const effort = body["reasoning_effort"] + delete body["reasoning_effort"] + const disabled = + effort === false || + (typeof effort === "string" && ["off", "none", "minimal", "disabled"].includes(effort.toLowerCase())) + if (!disabled && body["reasoning"] === undefined) body["reasoning"] = true + return JSON.stringify(body) +} + function wrapSSE(res: Response, ms: number, ctl: AbortController) { if (typeof ms !== "number" || ms <= 0) return res if (!res.body) return res @@ -1702,6 +1727,21 @@ const layer = Layer.effect( const existing = s.sdk.get(key) if (existing) return existing + // LLMTR speaks on/off thinking, not graded reasoning_effort — rewrite the + // outgoing body so an active (or carried-over) effort variant is accepted. + if (model.providerID === "llmtr") { + const priorFetch = options["fetch"] as + | ((input: any, init?: BunFetchRequestInit) => Promise) + | undefined + options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + if (typeof init?.body === "string") { + const rewritten = rewriteLLMTRReasoningBody(init.body) + if (rewritten !== init.body) init = { ...init, body: rewritten } + } + return (priorFetch ?? fetch)(input, init) + } + } + const customFetch = options["fetch"] const chunkTimeout = options["chunkTimeout"] const headerTimeout = options["headerTimeout"] diff --git a/packages/opencode/test/provider/llmtr-reasoning.test.ts b/packages/opencode/test/provider/llmtr-reasoning.test.ts new file mode 100644 index 0000000..0ac2c22 --- /dev/null +++ b/packages/opencode/test/provider/llmtr-reasoning.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { rewriteLLMTRReasoningBody } from "@/provider/provider" + +// LLMTR models expose thinking as an on/off toggle, not OpenAI-style graded +// reasoning_effort. rewriteLLMTRReasoningBody normalizes the outgoing request body +// so an active (or carried-over) effort variant does not get rejected by LLMTR. +describe("rewriteLLMTRReasoningBody", () => { + test("maps a graded reasoning_effort to on/off thinking", () => { + for (const effort of ["low", "medium", "high", "max", "xhigh"]) { + const out = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ model: "gemma-4", reasoning_effort: effort }))) + expect(out.reasoning_effort).toBeUndefined() + expect(out.reasoning).toBe(true) + expect(out.model).toBe("gemma-4") + } + }) + + test("keeps thinking off for explicit disable values", () => { + for (const effort of ["off", "none", "minimal", "disabled", "OFF"]) { + const out = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ reasoning_effort: effort }))) + expect(out.reasoning_effort).toBeUndefined() + expect(out.reasoning).toBeUndefined() + } + const boolOff = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ reasoning_effort: false }))) + expect(boolOff.reasoning_effort).toBeUndefined() + expect(boolOff.reasoning).toBeUndefined() + }) + + test("does not overwrite an explicit reasoning flag", () => { + const out = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ reasoning_effort: "high", reasoning: false }))) + expect(out.reasoning).toBe(false) + expect(out.reasoning_effort).toBeUndefined() + }) + + test("passes bodies without reasoning_effort through unchanged", () => { + const original = JSON.stringify({ model: "gemma-4", messages: [{ role: "user", content: "hi" }] }) + expect(rewriteLLMTRReasoningBody(original)).toBe(original) + }) + + test("returns non-JSON input unchanged", () => { + expect(rewriteLLMTRReasoningBody("not json")).toBe("not json") + expect(rewriteLLMTRReasoningBody("")).toBe("") + }) +}) From 8a58196fb0c4b7d975c3fcd5e2aabcdff79a7890 Mon Sep 17 00:00:00 2001 From: knowhycodata Date: Mon, 24 Aug 2026 13:47:30 +0300 Subject: [PATCH 3/4] feat(provider): add GLM 5.x and DeepSeek models to LLMTR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed the GLM 5.x family (5.3, 5.2, 5.1, 5) and the DeepSeek family (V4 Pro, V4 Flash, Chat, R1) — strong tool-use / reasoning models better suited to security work than the existing Turkey-hosted seeds. Metadata comes from the live LLMTR catalog; the forked live fetch still wins on id collisions so pricing stays current. These upstreams expose different thinking controls, so make the reasoning normalization per-model instead of blanket: classify each model as effort/boolean/none from its advertised parameters (in a dependency-free module to avoid a plugin-registry import cycle), and only rewrite an outgoing reasoning_effort for on/off ("boolean") or no-reasoning ("none") models. Graded-effort models (GLM 5.2/5.3, proxied OpenAI, ...) keep their native reasoning_effort untouched. Add classifier and seed-catalog tests. --- .../src/plugin/provider/llmtr-reasoning.ts | 31 +++++++ packages/core/src/plugin/provider/llmtr.ts | 86 +++++++++++++++++++ .../core/test/plugin/provider-llmtr.test.ts | 42 ++++++++- packages/opencode/src/provider/provider.ts | 57 +++++++----- .../test/provider/llmtr-reasoning.test.ts | 72 ++++++++++------ 5 files changed, 238 insertions(+), 50 deletions(-) create mode 100644 packages/core/src/plugin/provider/llmtr-reasoning.ts diff --git a/packages/core/src/plugin/provider/llmtr-reasoning.ts b/packages/core/src/plugin/provider/llmtr-reasoning.ts new file mode 100644 index 0000000..604dda3 --- /dev/null +++ b/packages/core/src/plugin/provider/llmtr-reasoning.ts @@ -0,0 +1,31 @@ +// Thinking-control classification for LLMTR models, kept in a dependency-free +// module so both the LLMTR plugin (which populates it) and the request path in +// the app package (which reads it) can share it without an import cycle through +// the provider-plugin registry. +// +// LLMTR fronts many upstreams whose "thinking" controls differ: some accept +// OpenAI-style graded reasoning_effort ("effort"), some only an on/off `reasoning` +// flag ("boolean"), and some take no reasoning parameter at all ("none"). + +export type ReasoningMode = "effort" | "boolean" | "none" + +// Per-model reasoning mode, keyed by catalog model id. Populated as models are +// projected (seed at startup, then the live `/v1/models` fetch). +const REASONING_MODES = new Map() + +/** Classifies a model's thinking control from its advertised parameters. */ +export function classifyReasoning(supported?: readonly string[]): ReasoningMode { + if (supported?.includes("reasoning_effort")) return "effort" + if (supported?.includes("reasoning")) return "boolean" + return "none" +} + +/** Records the reasoning mode for a catalog model id. */ +export function setLLMTRReasoningMode(modelID: string, supported?: readonly string[]): void { + REASONING_MODES.set(modelID, classifyReasoning(supported)) +} + +/** Reasoning mode for an LLMTR catalog model id, or undefined when unknown. */ +export function llmtrReasoningMode(modelID: string): ReasoningMode | undefined { + return REASONING_MODES.get(modelID) +} diff --git a/packages/core/src/plugin/provider/llmtr.ts b/packages/core/src/plugin/provider/llmtr.ts index 7239a40..862ddcf 100644 --- a/packages/core/src/plugin/provider/llmtr.ts +++ b/packages/core/src/plugin/provider/llmtr.ts @@ -5,6 +5,7 @@ import { Integration } from "../../integration" import { InstallationVersion } from "../../installation/version" import { ModelV2 } from "../../model" import { ProviderV2 } from "../../provider" +import { setLLMTRReasoningMode } from "./llmtr-reasoning" // LLMTR (https://llmtr.com) is a Turkey-hosted, OpenAI-compatible AI gateway that // fronts 200+ models (global providers + Turkey-hosted models) behind a single @@ -102,6 +103,90 @@ const SEED_MODELS: RemoteModel[] = [ top_provider: { context_length: 8192, max_completion_tokens: 8192 }, supported_parameters: ["temperature", "top_p"], }, + // GLM 5.x family (Z.ai, via LLMTR). Strong tool-use + reasoning; 5.2/5.3 accept + // OpenAI-style graded reasoning_effort, 5/5.1 expose an on/off reasoning flag. + { + id: "zai/glm-5.3", + name: "GLM-5.3", + created: 1787113304, + context_length: 1000000, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.00000126", completion: "0.00000396", input_cache_read: "0.000000234" }, + top_provider: { context_length: 1000000, max_completion_tokens: 131072 }, + supported_parameters: ["tools", "tool_choice", "reasoning", "reasoning_effort", "temperature", "top_p"], + }, + { + id: "zai/glm-5.2", + name: "GLM-5.2", + created: 1781716776, + context_length: 1000000, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.00000126", completion: "0.00000396", input_cache_read: "0.000000234" }, + top_provider: { context_length: 1000000, max_completion_tokens: 131072 }, + supported_parameters: ["tools", "tool_choice", "reasoning", "reasoning_effort", "temperature", "top_p"], + }, + { + id: "zai/glm-5.1", + name: "GLM-5.1", + created: 1776880302, + context_length: 128000, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.00000126", completion: "0.00000396", input_cache_read: "0.000000234" }, + top_provider: { context_length: 128000, max_completion_tokens: 128000 }, + supported_parameters: ["tools", "tool_choice", "reasoning", "temperature", "top_p"], + }, + { + id: "zai/glm-5", + name: "GLM-5", + created: 1776880302, + context_length: 128000, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.0000009", completion: "0.00000288", input_cache_read: "0.00000018" }, + top_provider: { context_length: 128000, max_completion_tokens: 128000 }, + supported_parameters: ["tools", "tool_choice", "reasoning", "temperature", "top_p"], + }, + // DeepSeek family (via LLMTR). 1M context, strong tool-use; thinking is internal + // (no reasoning/reasoning_effort parameter) so no thinking-effort variant applies. + { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek V4 Pro", + created: 1777118751, + context_length: 1000000, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.00000066", completion: "0.00000198", input_cache_read: "0.000000022" }, + top_provider: { context_length: 1000000, max_completion_tokens: 393216 }, + supported_parameters: ["tools", "tool_choice", "temperature", "top_p"], + }, + { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + created: 1777118751, + context_length: 1000000, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.00000022", completion: "0.00000066", input_cache_read: "0.000000007" }, + top_provider: { context_length: 1000000, max_completion_tokens: 393216 }, + supported_parameters: ["tools", "tool_choice", "temperature", "top_p"], + }, + { + id: "deepseek/deepseek-chat", + name: "DeepSeek Chat", + created: 1776880303, + context_length: 1000000, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.00000022", completion: "0.00000066", input_cache_read: "0.000000007" }, + top_provider: { context_length: 1000000, max_completion_tokens: 393216 }, + supported_parameters: ["tools", "tool_choice", "temperature", "top_p"], + }, + { + id: "deepseek/deepseek-reasoner", + name: "DeepSeek R1", + created: 1776880303, + context_length: 1000000, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.00000022", completion: "0.00000066", input_cache_read: "0.000000007" }, + top_provider: { context_length: 1000000, max_completion_tokens: 393216 }, + supported_parameters: ["tools", "tool_choice", "temperature", "top_p"], + }, ] const baseURL = () => process.env.LLMTR_BASE_URL?.trim() || DEFAULT_BASE_URL @@ -121,6 +206,7 @@ const modalities = (input: readonly string[] | undefined) => { /** Projects an LLMTR model description onto a catalog model draft. */ function applyModel(draft: ModelV2Info, model: RemoteModel) { + setLLMTRReasoningMode(model.id, model.supported_parameters) 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 diff --git a/packages/core/test/plugin/provider-llmtr.test.ts b/packages/core/test/plugin/provider-llmtr.test.ts index 83506ff..ef61e44 100644 --- a/packages/core/test/plugin/provider-llmtr.test.ts +++ b/packages/core/test/plugin/provider-llmtr.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { Effect } from "effect" import { Catalog } from "@pentestcode/core/catalog" import { Integration } from "@pentestcode/core/integration" @@ -7,6 +7,7 @@ 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 { classifyReasoning } from "@pentestcode/core/plugin/provider/llmtr-reasoning" import { ProviderV2 } from "@pentestcode/core/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -92,4 +93,43 @@ describe("LLMTRPlugin", () => { expect(asure.cost[0].input).toBeCloseTo(0.1, 10) }), ) + + it.effect("seeds the GLM 5.x and DeepSeek families", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + yield* addPlugin() + + const glm = required(yield* catalog.model.get(LLMTR, ModelV2.ID.make("zai/glm-5.3"))) + expect(glm.name).toBe("GLM-5.3") + expect(glm.capabilities.tools).toBe(true) + expect(glm.limit).toEqual({ context: 1000000, output: 131072 }) + // "0.00000126"/tok * 1e6 = 1.26/1M + expect(glm.cost[0].input).toBeCloseTo(1.26, 10) + + const glm52 = required(yield* catalog.model.get(LLMTR, ModelV2.ID.make("zai/glm-5.2"))) + expect(glm52.name).toBe("GLM-5.2") + + const dsPro = required(yield* catalog.model.get(LLMTR, ModelV2.ID.make("deepseek/deepseek-v4-pro"))) + expect(dsPro.name).toBe("DeepSeek V4 Pro") + expect(dsPro.capabilities.tools).toBe(true) + expect(dsPro.limit).toEqual({ context: 1000000, output: 393216 }) + + const r1 = required(yield* catalog.model.get(LLMTR, ModelV2.ID.make("deepseek/deepseek-reasoner"))) + expect(r1.name).toBe("DeepSeek R1") + }), + ) +}) + +describe("classifyReasoning", () => { + test("treats graded reasoning_effort as effort mode", () => { + expect(classifyReasoning(["tools", "reasoning", "reasoning_effort", "temperature"])).toBe("effort") + }) + test("treats an on/off reasoning flag as boolean mode", () => { + expect(classifyReasoning(["tools", "reasoning", "temperature"])).toBe("boolean") + }) + test("treats absent reasoning parameters as none mode", () => { + expect(classifyReasoning(["tools", "temperature", "top_p"])).toBe("none") + expect(classifyReasoning([])).toBe("none") + expect(classifyReasoning(undefined)).toBe("none") + }) }) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 11a288d..6c5120a 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -26,6 +26,7 @@ import { FSUtil } from "@pentestcode/core/fs-util" import { isRecord } from "@/util/record" import { optional } from "@pentestcode/core/schema" import { ProviderTransform } from "./transform" +import { llmtrReasoningMode } from "@pentestcode/core/plugin/provider/llmtr-reasoning" import { ProviderV2 } from "@pentestcode/core/provider" import { ModelV2 } from "@pentestcode/core/model" import { ModelStatus } from "./model-status" @@ -34,15 +35,18 @@ import { ProviderError } from "./error" const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 -// LLMTR exposes model "thinking" as an on/off toggle (`reasoning: true`, or a -// `:think` model-id suffix), not OpenAI-style graded reasoning_effort. An active -// effort variant — or one carried over from another model — would otherwise send -// `reasoning_effort` and be rejected by LLMTR. Given a JSON request-body string, -// this returns an equivalent body with any `reasoning_effort` rewritten into -// LLMTR's on/off form (a graded effort maps to `reasoning: true`; an explicit -// off/none/minimal/disabled leaves thinking off). The original string is returned -// unchanged when it is not JSON or carries no `reasoning_effort`. Exported for tests. -export function rewriteLLMTRReasoningBody(bodyText: string): string { +// LLMTR fronts many upstreams whose "thinking" controls differ. This normalizes an +// outgoing reasoning_effort for a model that does NOT take OpenAI-style graded +// effort, given the model's reasoning mode: +// - "boolean": the model exposes an on/off `reasoning` flag — a graded effort +// maps to `reasoning: true`; an explicit off/none/minimal/disabled leaves +// thinking off; an existing `reasoning` flag is preserved. +// - "none": the model takes no reasoning parameter — strip reasoning_effort and +// add nothing. +// Effort-capable models are never passed here (their reasoning_effort is valid). +// The original string is returned unchanged when it is not JSON or carries no +// reasoning_effort. Exported for tests. +export function rewriteLLMTRReasoningBody(bodyText: string, mode: "boolean" | "none"): string { let body: Record try { body = JSON.parse(bodyText) @@ -52,10 +56,12 @@ export function rewriteLLMTRReasoningBody(bodyText: string): string { if (!isRecord(body) || !("reasoning_effort" in body)) return bodyText const effort = body["reasoning_effort"] delete body["reasoning_effort"] - const disabled = - effort === false || - (typeof effort === "string" && ["off", "none", "minimal", "disabled"].includes(effort.toLowerCase())) - if (!disabled && body["reasoning"] === undefined) body["reasoning"] = true + if (mode === "boolean") { + const disabled = + effort === false || + (typeof effort === "string" && ["off", "none", "minimal", "disabled"].includes(effort.toLowerCase())) + if (!disabled && body["reasoning"] === undefined) body["reasoning"] = true + } return JSON.stringify(body) } @@ -1727,18 +1733,23 @@ const layer = Layer.effect( const existing = s.sdk.get(key) if (existing) return existing - // LLMTR speaks on/off thinking, not graded reasoning_effort — rewrite the - // outgoing body so an active (or carried-over) effort variant is accepted. + // LLMTR fronts upstreams with different thinking controls. Models that take + // OpenAI-style graded reasoning_effort are left untouched; models that expose + // only an on/off flag ("boolean") or no reasoning parameter ("none") have an + // outgoing reasoning_effort normalized so the request is accepted. if (model.providerID === "llmtr") { - const priorFetch = options["fetch"] as - | ((input: any, init?: BunFetchRequestInit) => Promise) - | undefined - options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { - if (typeof init?.body === "string") { - const rewritten = rewriteLLMTRReasoningBody(init.body) - if (rewritten !== init.body) init = { ...init, body: rewritten } + const mode = llmtrReasoningMode(model.id) + if (mode === "boolean" || mode === "none") { + const priorFetch = options["fetch"] as + | ((input: any, init?: BunFetchRequestInit) => Promise) + | undefined + options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + if (typeof init?.body === "string") { + const rewritten = rewriteLLMTRReasoningBody(init.body, mode) + if (rewritten !== init.body) init = { ...init, body: rewritten } + } + return (priorFetch ?? fetch)(input, init) } - return (priorFetch ?? fetch)(input, init) } } diff --git a/packages/opencode/test/provider/llmtr-reasoning.test.ts b/packages/opencode/test/provider/llmtr-reasoning.test.ts index 0ac2c22..a620c05 100644 --- a/packages/opencode/test/provider/llmtr-reasoning.test.ts +++ b/packages/opencode/test/provider/llmtr-reasoning.test.ts @@ -1,43 +1,63 @@ import { describe, expect, test } from "bun:test" import { rewriteLLMTRReasoningBody } from "@/provider/provider" -// LLMTR models expose thinking as an on/off toggle, not OpenAI-style graded -// reasoning_effort. rewriteLLMTRReasoningBody normalizes the outgoing request body -// so an active (or carried-over) effort variant does not get rejected by LLMTR. +// LLMTR fronts upstreams with different thinking controls. rewriteLLMTRReasoningBody +// normalizes an outgoing reasoning_effort for models that do NOT take graded effort: +// "boolean" models get `reasoning: true`, "none" models just have it stripped. describe("rewriteLLMTRReasoningBody", () => { - test("maps a graded reasoning_effort to on/off thinking", () => { - for (const effort of ["low", "medium", "high", "max", "xhigh"]) { - const out = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ model: "gemma-4", reasoning_effort: effort }))) - expect(out.reasoning_effort).toBeUndefined() - expect(out.reasoning).toBe(true) - expect(out.model).toBe("gemma-4") - } - }) + describe("boolean mode (on/off reasoning flag)", () => { + test("maps a graded reasoning_effort to reasoning: true", () => { + for (const effort of ["low", "medium", "high", "max", "xhigh"]) { + const out = JSON.parse( + rewriteLLMTRReasoningBody(JSON.stringify({ model: "gemma-4", reasoning_effort: effort }), "boolean"), + ) + expect(out.reasoning_effort).toBeUndefined() + expect(out.reasoning).toBe(true) + expect(out.model).toBe("gemma-4") + } + }) + + test("keeps thinking off for explicit disable values", () => { + for (const effort of ["off", "none", "minimal", "disabled", "OFF"]) { + const out = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ reasoning_effort: effort }), "boolean")) + expect(out.reasoning_effort).toBeUndefined() + expect(out.reasoning).toBeUndefined() + } + const boolOff = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ reasoning_effort: false }), "boolean")) + expect(boolOff.reasoning_effort).toBeUndefined() + expect(boolOff.reasoning).toBeUndefined() + }) - test("keeps thinking off for explicit disable values", () => { - for (const effort of ["off", "none", "minimal", "disabled", "OFF"]) { - const out = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ reasoning_effort: effort }))) + test("does not overwrite an explicit reasoning flag", () => { + const out = JSON.parse( + rewriteLLMTRReasoningBody(JSON.stringify({ reasoning_effort: "high", reasoning: false }), "boolean"), + ) + expect(out.reasoning).toBe(false) expect(out.reasoning_effort).toBeUndefined() - expect(out.reasoning).toBeUndefined() - } - const boolOff = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ reasoning_effort: false }))) - expect(boolOff.reasoning_effort).toBeUndefined() - expect(boolOff.reasoning).toBeUndefined() + }) }) - test("does not overwrite an explicit reasoning flag", () => { - const out = JSON.parse(rewriteLLMTRReasoningBody(JSON.stringify({ reasoning_effort: "high", reasoning: false }))) - expect(out.reasoning).toBe(false) - expect(out.reasoning_effort).toBeUndefined() + describe("none mode (no reasoning parameter)", () => { + test("strips reasoning_effort without adding a reasoning flag", () => { + for (const effort of ["low", "high", "max"]) { + const out = JSON.parse( + rewriteLLMTRReasoningBody(JSON.stringify({ model: "deepseek-v4-pro", reasoning_effort: effort }), "none"), + ) + expect(out.reasoning_effort).toBeUndefined() + expect(out.reasoning).toBeUndefined() + expect(out.model).toBe("deepseek-v4-pro") + } + }) }) test("passes bodies without reasoning_effort through unchanged", () => { const original = JSON.stringify({ model: "gemma-4", messages: [{ role: "user", content: "hi" }] }) - expect(rewriteLLMTRReasoningBody(original)).toBe(original) + expect(rewriteLLMTRReasoningBody(original, "boolean")).toBe(original) + expect(rewriteLLMTRReasoningBody(original, "none")).toBe(original) }) test("returns non-JSON input unchanged", () => { - expect(rewriteLLMTRReasoningBody("not json")).toBe("not json") - expect(rewriteLLMTRReasoningBody("")).toBe("") + expect(rewriteLLMTRReasoningBody("not json", "boolean")).toBe("not json") + expect(rewriteLLMTRReasoningBody("", "none")).toBe("") }) }) From 64cb77d127dbfccf65bfdad29ece4eabfc64196c Mon Sep 17 00:00:00 2001 From: knowhycodata Date: Mon, 24 Aug 2026 21:07:02 +0300 Subject: [PATCH 4/4] =?UTF-8?q?refactor(provider):=20address=20LLMTR=20rev?= =?UTF-8?q?iew=20=E2=80=94=20gate=20fetch,=20de-special-case=20reasoning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Responds to PR review feedback: - Gate the live `/v1/models` fetch behind API-key presence (env var or an active llmtr integration connection). No key → no request at init, matching how the other providers behave. Refetch on ConnectionUpdated so `auth login` mid-session still populates the full catalog. - Remove the `if (model.providerID === "llmtr")` branch from the shared request path (opencode/provider.ts). Introduce a generic per-provider outgoing-body rewriter registry (request-transform.ts); the LLMTR reasoning rewrite now lives entirely in its own core module and registers itself. The interactive path uses BUNDLED_PROVIDERS (not the V2 ctx.aisdk.sdk hook), so the rewrite stays in that path but is no longer provider-specific there. - Move rewriteLLMTRReasoningBody + its tests into core alongside the reasoning module; consolidate reasoning unit tests into packages/core. DeepSeek/GLM seed models are intentionally kept (LLMTR prices them below list / periodically free, and they live under the `llmtr` namespace — no catalog collision). Co-Authored-By: Claude Opus 4.8 --- .../src/plugin/provider/llmtr-reasoning.ts | 55 +++++++++++++++-- packages/core/src/plugin/provider/llmtr.ts | 35 +++++++++-- .../src/plugin/provider/request-transform.ts | 27 ++++++++ .../test/plugin}/llmtr-reasoning.test.ts | 43 ++++++++++++- .../core/test/plugin/provider-llmtr.test.ts | 21 ++----- packages/opencode/src/provider/provider.ts | 61 +++++-------------- 6 files changed, 166 insertions(+), 76 deletions(-) create mode 100644 packages/core/src/plugin/provider/request-transform.ts rename packages/{opencode/test/provider => core/test/plugin}/llmtr-reasoning.test.ts (59%) diff --git a/packages/core/src/plugin/provider/llmtr-reasoning.ts b/packages/core/src/plugin/provider/llmtr-reasoning.ts index 604dda3..8acdf27 100644 --- a/packages/core/src/plugin/provider/llmtr-reasoning.ts +++ b/packages/core/src/plugin/provider/llmtr-reasoning.ts @@ -1,11 +1,15 @@ -// Thinking-control classification for LLMTR models, kept in a dependency-free -// module so both the LLMTR plugin (which populates it) and the request path in -// the app package (which reads it) can share it without an import cycle through -// the provider-plugin registry. +// Thinking-control handling for LLMTR models, kept in a dependency-free module so +// the LLMTR plugin (which populates the per-model mode) and the request path (which +// applies the body rewrite) can share it without an import cycle through the +// provider-plugin registry. // // LLMTR fronts many upstreams whose "thinking" controls differ: some accept // OpenAI-style graded reasoning_effort ("effort"), some only an on/off `reasoning` -// flag ("boolean"), and some take no reasoning parameter at all ("none"). +// flag ("boolean"), and some take no reasoning parameter at all ("none"). The +// rewriter is registered generically (see request-transform.ts) so the shared +// request path carries no LLMTR-specific branch. + +import { registerBodyRewriter } from "./request-transform" export type ReasoningMode = "effort" | "boolean" | "none" @@ -29,3 +33,44 @@ export function setLLMTRReasoningMode(modelID: string, supported?: readonly stri export function llmtrReasoningMode(modelID: string): ReasoningMode | undefined { return REASONING_MODES.get(modelID) } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +// Normalizes an outgoing reasoning_effort for a model that does NOT take +// OpenAI-style graded effort, given the model's reasoning mode: +// - "boolean": the model exposes an on/off `reasoning` flag — a graded effort +// maps to `reasoning: true`; an explicit off/none/minimal/disabled leaves +// thinking off; an existing `reasoning` flag is preserved. +// - "none": the model takes no reasoning parameter — strip reasoning_effort and +// add nothing. +// Effort-capable models are never passed here (their reasoning_effort is valid). +// The original string is returned unchanged when it is not JSON or carries no +// reasoning_effort. Exported for tests. +export function rewriteLLMTRReasoningBody(bodyText: string, mode: "boolean" | "none"): string { + let body: Record + try { + body = JSON.parse(bodyText) + } catch { + return bodyText + } + if (!isRecord(body) || !("reasoning_effort" in body)) return bodyText + const effort = body["reasoning_effort"] + delete body["reasoning_effort"] + if (mode === "boolean") { + const disabled = + effort === false || + (typeof effort === "string" && ["off", "none", "minimal", "disabled"].includes(effort.toLowerCase())) + if (!disabled && body["reasoning"] === undefined) body["reasoning"] = true + } + return JSON.stringify(body) +} + +// Register the LLMTR body rewriter generically. Effort-capable ("effort") and +// unknown models return undefined, so only boolean/none models are rewritten. +registerBodyRewriter("llmtr", (modelID) => { + const mode = llmtrReasoningMode(modelID) + if (mode !== "boolean" && mode !== "none") return undefined + return (body) => rewriteLLMTRReasoningBody(body, mode) +}) diff --git a/packages/core/src/plugin/provider/llmtr.ts b/packages/core/src/plugin/provider/llmtr.ts index 862ddcf..9c590cf 100644 --- a/packages/core/src/plugin/provider/llmtr.ts +++ b/packages/core/src/plugin/provider/llmtr.ts @@ -1,6 +1,7 @@ -import { Effect } from "effect" +import { Effect, Stream } from "effect" import type { ModelV2Info } from "@pentestcode/sdk/v2/types" import { define } from "../internal" +import { EventV2 } from "../../event" import { Integration } from "../../integration" import { InstallationVersion } from "../../installation/version" import { ModelV2 } from "../../model" @@ -14,9 +15,10 @@ import { setLLMTRReasoningMode } from "./llmtr-reasoning" // whose bearer key is resolved from the `llmtr` integration (API key / env var). // // Models are discovered live from the public `/v1/models` catalog (OpenRouter-style -// schema). The fetch is best-effort and forked, so registration never blocks on the -// network; a curated seed of Turkey-hosted flagships keeps the provider usable -// offline and guarantees they are always present. +// schema), but only once the user has an LLMTR key configured — with no key the +// provider is unavailable anyway, so no request is made at init. The fetch is +// best-effort and forked (never blocks registration); a curated seed of +// Turkey-hosted flagships keeps the provider usable offline and always present. const PROVIDER_ID = "llmtr" const PROVIDER = ProviderV2.ID.make(PROVIDER_ID) @@ -282,13 +284,34 @@ export const LLMTRPlugin = define({ // Opt-out hook for hermetic tests / fully offline runs. if (process.env.LLMTR_SKIP_REMOTE_MODELS === "1") return - yield* Effect.forkScoped( + // Only hit the network when the user actually has an LLMTR key configured. With + // no key the provider is unavailable anyway, so the seed list is all we need — + // other providers likewise do no remote work at init. + const hasKey = () => + process.env[ENV_KEY]?.trim() + ? Effect.succeed(true) + : ctx.integration.connection.active(INTEGRATION_ID).pipe( + Effect.map((connection) => connection !== undefined), + Effect.catch(() => Effect.succeed(false)), + ) + + const refresh = () => Effect.gen(function* () { + if (!(yield* hasKey())) return 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() - }), + }) + + const events = yield* EventV2.Service + // Refetch when a key is added/removed mid-session (e.g. `auth login`), matching + // how the other integration-backed providers refresh. + yield* events.subscribe(Integration.Event.ConnectionUpdated).pipe( + Stream.filter((event) => event.data.integrationID === INTEGRATION_ID), + Stream.runForEach(() => refresh()), + Effect.forkScoped({ startImmediately: true }), ) + yield* refresh().pipe(Effect.forkScoped) }), }) diff --git a/packages/core/src/plugin/provider/request-transform.ts b/packages/core/src/plugin/provider/request-transform.ts new file mode 100644 index 0000000..54cdf78 --- /dev/null +++ b/packages/core/src/plugin/provider/request-transform.ts @@ -0,0 +1,27 @@ +// Generic, provider-agnostic registry of outgoing request-body rewriters. +// +// The shared request path (packages/opencode/src/provider/provider.ts) must not +// carry per-provider special cases like `if (providerID === "llmtr")`. Instead a +// provider plugin registers a rewriter for its own id here, and the request path +// looks one up generically — it never needs to know which providers have one. +// +// A factory is resolved per (providerID, modelID) so a provider can vary the +// rewrite by model (or return undefined to leave a request untouched). + +/** Rewrites a serialized JSON request body, returning it unchanged when nothing applies. */ +export type BodyRewriter = (body: string) => string + +/** Produces a rewriter for a given model id, or undefined when none applies. */ +export type BodyRewriterFactory = (modelID: string) => BodyRewriter | undefined + +const FACTORIES = new Map() + +/** Registers a provider's outgoing-body rewriter factory (last registration wins). */ +export function registerBodyRewriter(providerID: string, factory: BodyRewriterFactory): void { + FACTORIES.set(providerID, factory) +} + +/** Resolves the rewriter for a (providerID, modelID), or undefined when none applies. */ +export function resolveBodyRewriter(providerID: string, modelID: string): BodyRewriter | undefined { + return FACTORIES.get(providerID)?.(modelID) +} diff --git a/packages/opencode/test/provider/llmtr-reasoning.test.ts b/packages/core/test/plugin/llmtr-reasoning.test.ts similarity index 59% rename from packages/opencode/test/provider/llmtr-reasoning.test.ts rename to packages/core/test/plugin/llmtr-reasoning.test.ts index a620c05..2b63ae8 100644 --- a/packages/opencode/test/provider/llmtr-reasoning.test.ts +++ b/packages/core/test/plugin/llmtr-reasoning.test.ts @@ -1,5 +1,24 @@ import { describe, expect, test } from "bun:test" -import { rewriteLLMTRReasoningBody } from "@/provider/provider" +import { + classifyReasoning, + rewriteLLMTRReasoningBody, + setLLMTRReasoningMode, +} from "@pentestcode/core/plugin/provider/llmtr-reasoning" +import { resolveBodyRewriter } from "@pentestcode/core/plugin/provider/request-transform" + +describe("classifyReasoning", () => { + test("treats graded reasoning_effort as effort mode", () => { + expect(classifyReasoning(["tools", "reasoning", "reasoning_effort", "temperature"])).toBe("effort") + }) + test("treats an on/off reasoning flag as boolean mode", () => { + expect(classifyReasoning(["tools", "reasoning", "temperature"])).toBe("boolean") + }) + test("treats absent reasoning parameters as none mode", () => { + expect(classifyReasoning(["tools", "temperature", "top_p"])).toBe("none") + expect(classifyReasoning([])).toBe("none") + expect(classifyReasoning(undefined)).toBe("none") + }) +}) // LLMTR fronts upstreams with different thinking controls. rewriteLLMTRReasoningBody // normalizes an outgoing reasoning_effort for models that do NOT take graded effort: @@ -61,3 +80,25 @@ describe("rewriteLLMTRReasoningBody", () => { expect(rewriteLLMTRReasoningBody("", "none")).toBe("") }) }) + +// The llmtr module registers its rewriter into the generic request-transform +// registry, so the shared request path resolves it without any llmtr-specific code. +describe("llmtr body-rewriter registration", () => { + test("boolean-mode models get a rewriter that flips reasoning on", () => { + setLLMTRReasoningMode("gemma-4", ["reasoning", "temperature"]) + const rewrite = resolveBodyRewriter("llmtr", "gemma-4") + expect(rewrite).toBeDefined() + const out = JSON.parse(rewrite!(JSON.stringify({ reasoning_effort: "high" }))) + expect(out.reasoning).toBe(true) + expect(out.reasoning_effort).toBeUndefined() + }) + + test("effort-capable models get no rewriter (their reasoning_effort is valid)", () => { + setLLMTRReasoningMode("zai/glm-5.3", ["reasoning", "reasoning_effort", "temperature"]) + expect(resolveBodyRewriter("llmtr", "zai/glm-5.3")).toBeUndefined() + }) + + test("unknown provider ids resolve to no rewriter", () => { + expect(resolveBodyRewriter("openai", "gpt-5")).toBeUndefined() + }) +}) diff --git a/packages/core/test/plugin/provider-llmtr.test.ts b/packages/core/test/plugin/provider-llmtr.test.ts index ef61e44..acc7819 100644 --- a/packages/core/test/plugin/provider-llmtr.test.ts +++ b/packages/core/test/plugin/provider-llmtr.test.ts @@ -1,13 +1,13 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@pentestcode/core/catalog" +import { EventV2 } from "@pentestcode/core/event" 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 { classifyReasoning } from "@pentestcode/core/plugin/provider/llmtr-reasoning" import { ProviderV2 } from "@pentestcode/core/provider" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -21,7 +21,8 @@ const it = testEffect(PluginTestLayer) const addPlugin = Effect.fn(function* () { const plugin = yield* PluginV2.Service const host = yield* PluginHost.make(plugin) - yield* LLMTRPlugin.effect(host) + const events = yield* EventV2.Service + yield* LLMTRPlugin.effect(host).pipe(Effect.provideService(EventV2.Service, events)) }) function required(value: T | undefined): T { @@ -119,17 +120,3 @@ describe("LLMTRPlugin", () => { }), ) }) - -describe("classifyReasoning", () => { - test("treats graded reasoning_effort as effort mode", () => { - expect(classifyReasoning(["tools", "reasoning", "reasoning_effort", "temperature"])).toBe("effort") - }) - test("treats an on/off reasoning flag as boolean mode", () => { - expect(classifyReasoning(["tools", "reasoning", "temperature"])).toBe("boolean") - }) - test("treats absent reasoning parameters as none mode", () => { - expect(classifyReasoning(["tools", "temperature", "top_p"])).toBe("none") - expect(classifyReasoning([])).toBe("none") - expect(classifyReasoning(undefined)).toBe("none") - }) -}) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6c5120a..3a749ec 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -26,7 +26,7 @@ import { FSUtil } from "@pentestcode/core/fs-util" import { isRecord } from "@/util/record" import { optional } from "@pentestcode/core/schema" import { ProviderTransform } from "./transform" -import { llmtrReasoningMode } from "@pentestcode/core/plugin/provider/llmtr-reasoning" +import { resolveBodyRewriter } from "@pentestcode/core/plugin/provider/request-transform" import { ProviderV2 } from "@pentestcode/core/provider" import { ModelV2 } from "@pentestcode/core/model" import { ModelStatus } from "./model-status" @@ -35,36 +35,6 @@ import { ProviderError } from "./error" const OPENAI_HEADER_TIMEOUT_DEFAULT = 10_000 -// LLMTR fronts many upstreams whose "thinking" controls differ. This normalizes an -// outgoing reasoning_effort for a model that does NOT take OpenAI-style graded -// effort, given the model's reasoning mode: -// - "boolean": the model exposes an on/off `reasoning` flag — a graded effort -// maps to `reasoning: true`; an explicit off/none/minimal/disabled leaves -// thinking off; an existing `reasoning` flag is preserved. -// - "none": the model takes no reasoning parameter — strip reasoning_effort and -// add nothing. -// Effort-capable models are never passed here (their reasoning_effort is valid). -// The original string is returned unchanged when it is not JSON or carries no -// reasoning_effort. Exported for tests. -export function rewriteLLMTRReasoningBody(bodyText: string, mode: "boolean" | "none"): string { - let body: Record - try { - body = JSON.parse(bodyText) - } catch { - return bodyText - } - if (!isRecord(body) || !("reasoning_effort" in body)) return bodyText - const effort = body["reasoning_effort"] - delete body["reasoning_effort"] - if (mode === "boolean") { - const disabled = - effort === false || - (typeof effort === "string" && ["off", "none", "minimal", "disabled"].includes(effort.toLowerCase())) - if (!disabled && body["reasoning"] === undefined) body["reasoning"] = true - } - return JSON.stringify(body) -} - function wrapSSE(res: Response, ms: number, ctl: AbortController) { if (typeof ms !== "number" || ms <= 0) return res if (!res.body) return res @@ -1733,23 +1703,20 @@ const layer = Layer.effect( const existing = s.sdk.get(key) if (existing) return existing - // LLMTR fronts upstreams with different thinking controls. Models that take - // OpenAI-style graded reasoning_effort are left untouched; models that expose - // only an on/off flag ("boolean") or no reasoning parameter ("none") have an - // outgoing reasoning_effort normalized so the request is accepted. - if (model.providerID === "llmtr") { - const mode = llmtrReasoningMode(model.id) - if (mode === "boolean" || mode === "none") { - const priorFetch = options["fetch"] as - | ((input: any, init?: BunFetchRequestInit) => Promise) - | undefined - options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { - if (typeof init?.body === "string") { - const rewritten = rewriteLLMTRReasoningBody(init.body, mode) - if (rewritten !== init.body) init = { ...init, body: rewritten } - } - return (priorFetch ?? fetch)(input, init) + // Providers may register an outgoing-body rewriter for their own id (e.g. + // LLMTR normalizing reasoning controls). Looked up generically — the shared + // path stays free of per-provider branches. + const rewriteBody = resolveBodyRewriter(model.providerID, model.id) + if (rewriteBody) { + const priorFetch = options["fetch"] as + | ((input: any, init?: BunFetchRequestInit) => Promise) + | undefined + options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + if (typeof init?.body === "string") { + const rewritten = rewriteBody(init.body) + if (rewritten !== init.body) init = { ...init, body: rewritten } } + return (priorFetch ?? fetch)(input, init) } }