diff --git a/README.md b/README.md index 1bd10f7e..592a64f4 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 4d6f9eec..2aedd7ae 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() + +/** 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) +} + +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 new file mode 100644 index 00000000..9c590cf2 --- /dev/null +++ b/packages/core/src/plugin/provider/llmtr.ts @@ -0,0 +1,317 @@ +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" +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 +// `/v1/chat/completions` endpoint. It plugs in exactly like the other gateway +// providers (openrouter/nvidia/zenmux): an `@ai-sdk/openai-compatible` provider +// 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), 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) +const INTEGRATION_ID = Integration.ID.make(PROVIDER_ID) +const DISPLAY_NAME = "LLMTR" +const PACKAGE = "@ai-sdk/openai-compatible" +const DEFAULT_BASE_URL = "https://llmtr.com/v1" +const ENV_KEY = "LLMTR_API_KEY" + +// Catalog costs are expressed per 1M tokens; LLMTR (like OpenRouter) prices per +// token as decimal strings, so scale up by 1e6. +const PRICE_SCALE = 1_000_000 + +const KNOWN_MODALITIES = new Set(["text", "image", "audio", "video", "pdf"]) + +/** Subset of the OpenRouter-style model object returned by `GET /v1/models`. */ +interface RemoteModel { + id: string + name?: string + created?: number + context_length?: number + architecture?: { + input_modalities?: readonly string[] + output_modalities?: readonly string[] + } + pricing?: { + prompt?: string + completion?: string + input_cache_read?: string + input_cache_write?: string + } + top_provider?: { + context_length?: number + max_completion_tokens?: number + } + supported_parameters?: readonly string[] +} + +// Real values pulled from the live LLMTR catalog. Used offline and merged with the +// live fetch (live entries win on id collisions so pricing stays current). +const SEED_MODELS: RemoteModel[] = [ + { + id: "llmtr/muse-glimmer-30b-tr", + name: "Muse Glimmer 30B (Türkiye)", + context_length: 131072, + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + pricing: { prompt: "0.000002", completion: "0.000005", input_cache_read: "0.0000005" }, + top_provider: { context_length: 131072, max_completion_tokens: 131072 }, + supported_parameters: ["tools", "tool_choice", "reasoning", "temperature", "top_p"], + }, + { + id: "llmtr/gemma-4", + name: "Gemma 4", + context_length: 131072, + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + pricing: { prompt: "0.000002", completion: "0.000005", input_cache_read: "0.0000005" }, + top_provider: { context_length: 131072, max_completion_tokens: 131072 }, + supported_parameters: ["tools", "tool_choice", "reasoning", "temperature", "top_p"], + }, + { + id: "llmtr/qwen3-6-35b", + name: "Qwen 3.6 35B-A3B", + context_length: 262144, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.000005", completion: "0.000005" }, + top_provider: { context_length: 262144, max_completion_tokens: 65536 }, + supported_parameters: ["tools", "tool_choice", "reasoning", "temperature", "top_p"], + }, + { + id: "llmtr/trendyol-asure-12b", + name: "Trendyol Asure 12B", + context_length: 40960, + architecture: { input_modalities: ["text", "image"], output_modalities: ["text"] }, + pricing: { prompt: "0.0000001", completion: "0.0000005", input_cache_read: "0.000000025" }, + top_provider: { context_length: 40960, max_completion_tokens: 40960 }, + supported_parameters: ["temperature", "top_p"], + }, + { + id: "llmtr/magibu-11b-v8", + name: "Magibu 11B v8", + context_length: 8192, + architecture: { input_modalities: ["text"], output_modalities: ["text"] }, + pricing: { prompt: "0.0000001", completion: "0.0000005" }, + 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 + +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) { + 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 + 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 + + // 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 00000000..54cdf78a --- /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/core/test/plugin/llmtr-reasoning.test.ts b/packages/core/test/plugin/llmtr-reasoning.test.ts new file mode 100644 index 00000000..2b63ae84 --- /dev/null +++ b/packages/core/test/plugin/llmtr-reasoning.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test" +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: +// "boolean" models get `reasoning: true`, "none" models just have it stripped. +describe("rewriteLLMTRReasoningBody", () => { + 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("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() + }) + }) + + 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, "boolean")).toBe(original) + expect(rewriteLLMTRReasoningBody(original, "none")).toBe(original) + }) + + test("returns non-JSON input unchanged", () => { + expect(rewriteLLMTRReasoningBody("not json", "boolean")).toBe("not json") + 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 new file mode 100644 index 00000000..acc78190 --- /dev/null +++ b/packages/core/test/plugin/provider-llmtr.test.ts @@ -0,0 +1,122 @@ +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 { 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) + const events = yield* EventV2.Service + yield* LLMTRPlugin.effect(host).pipe(Effect.provideService(EventV2.Service, events)) +}) + +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) + }), + ) + + 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") + }), + ) +}) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 709faff4..3a749ecc 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 { 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" @@ -1702,6 +1703,23 @@ const layer = Layer.effect( const existing = s.sdk.get(key) if (existing) return existing + // 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) + } + } + const customFetch = options["fetch"] const chunkTimeout = options["chunkTimeout"] const headerTimeout = options["headerTimeout"]