diff --git a/p2-tenantrag/.env.example b/p2-tenantrag/.env.example index f93c6c5..ebd3562 100644 --- a/p2-tenantrag/.env.example +++ b/p2-tenantrag/.env.example @@ -1,2 +1,4 @@ P2_VOYAGE_API_KEY= P2_OPENROUTER_API_KEY= +P2_OPENROUTER_MODEL=openrouter/free +P2_OPENROUTER_ALLOW_PAID=false diff --git a/p2-tenantrag/README.md b/p2-tenantrag/README.md index 9b89347..7233f0d 100644 --- a/p2-tenantrag/README.md +++ b/p2-tenantrag/README.md @@ -33,6 +33,11 @@ Ada / Leo / Mallory ── Device Flow ──→ Tutorial IdP Set `P2_VOYAGE_API_KEY` and `P2_OPENROUTER_API_KEY` in `.env` before preparing the corpus or starting Docker. Setup embeds synthetic PDF chunks with Voyage and checks OpenRouter generation; requests also consume provider quota. Do not submit private queries. +`P2_OPENROUTER_MODEL` defaults to `openrouter/free`. To select another free +model, set its OpenRouter model ID in `.env`. A model that may incur charges +also requires `P2_OPENROUTER_ALLOW_PAID=true`; otherwise requests retain a +zero-price routing cap. The Voyage embedding model stays fixed, so changing +the OpenRouter answer model does not require rebuilding the corpus. Start the application and its own IdP: diff --git a/p2-tenantrag/scripts/setup.ts b/p2-tenantrag/scripts/setup.ts index af28355..482cbf7 100644 --- a/p2-tenantrag/scripts/setup.ts +++ b/p2-tenantrag/scripts/setup.ts @@ -35,4 +35,4 @@ console.log( `Synchronized ${merged.synchronizedKeys.join(", ") || "no"} environment keys.`, ); console.log(`Verified five PDFs and built ${recordCount} vector records.`); -console.log(`OpenRouter free-model smoke selected ${selectedModel}.`); +console.log(`OpenRouter smoke selected ${selectedModel}.`); diff --git a/p2-tenantrag/src/config/project-config.ts b/p2-tenantrag/src/config/project-config.ts index fcc1660..36a3d29 100644 --- a/p2-tenantrag/src/config/project-config.ts +++ b/p2-tenantrag/src/config/project-config.ts @@ -13,7 +13,8 @@ export type P2Config = Readonly<{ voyageModel: "voyage-4-lite"; voyageDimensions: 256; openRouterApiKey: string; - openRouterModel: "openrouter/free"; + openRouterModel: string; + openRouterAllowPaid: boolean; providerTimeoutMs: number; }>; @@ -23,6 +24,27 @@ function required(env: NodeJS.ProcessEnv, name: string): string { return value; } +function openRouterModel(env: NodeJS.ProcessEnv, allowPaid: boolean): string { + const model = (env.P2_OPENROUTER_MODEL ?? "openrouter/free").trim(); + if (!model || /\s/.test(model)) { + throw new Error("P2_OPENROUTER_MODEL must be a model ID without spaces"); + } + if (!allowPaid && model !== "openrouter/free" && !model.endsWith(":free")) { + throw new Error( + "P2_OPENROUTER_ALLOW_PAID=true is required for a paid model", + ); + } + return model; +} + +function allowPaid(env: NodeJS.ProcessEnv): boolean { + const value = env.P2_OPENROUTER_ALLOW_PAID?.trim() ?? "false"; + if (value !== "true" && value !== "false") { + throw new Error("P2_OPENROUTER_ALLOW_PAID must be true or false"); + } + return value === "true"; +} + function httpUrl(value: string, name: string): string { const url = new URL(value); if (url.protocol !== "http:" && url.protocol !== "https:") { @@ -50,6 +72,7 @@ export function loadConfig( currentDirectory = process.cwd(), ): P2Config { const projectRoot = resolve(env.P2_PROJECT_ROOT?.trim() || currentDirectory); + const openRouterAllowPaid = allowPaid(env); const voyageDimensions = integer( env.P2_VOYAGE_DIMENSIONS, 256, @@ -77,7 +100,8 @@ export function loadConfig( voyageModel: "voyage-4-lite", voyageDimensions: voyageDimensions as 256, openRouterApiKey: required(env, "P2_OPENROUTER_API_KEY"), - openRouterModel: "openrouter/free", + openRouterModel: openRouterModel(env, openRouterAllowPaid), + openRouterAllowPaid, providerTimeoutMs: integer( env.P2_PROVIDER_TIMEOUT_MS, 15_000, diff --git a/p2-tenantrag/src/rag/providers/openrouter.ts b/p2-tenantrag/src/rag/providers/openrouter.ts index c86c0b1..4c3e119 100644 --- a/p2-tenantrag/src/rag/providers/openrouter.ts +++ b/p2-tenantrag/src/rag/providers/openrouter.ts @@ -17,6 +17,7 @@ export type OpenRouterClient = Readonly<{ type OpenRouterClientOptions = Readonly<{ apiKey: string; model: string; + allowPaid?: boolean; timeoutMs: number; fetch?: typeof fetch; }>; @@ -81,8 +82,11 @@ export function createOpenRouterClient( }, body: JSON.stringify({ model: options.model, + ...(!options.allowPaid + ? { provider: { max_price: { prompt: 0, completion: 0 } } } + : {}), max_completion_tokens: 512, - // The free router may select a reasoning model; request only answer text. + // Request answer text even when the selected model supports reasoning. reasoning: { effort: "minimal", exclude: true }, messages: [ { diff --git a/p2-tenantrag/src/rag/setup.ts b/p2-tenantrag/src/rag/setup.ts index 1e88f1e..b7e4abb 100644 --- a/p2-tenantrag/src/rag/setup.ts +++ b/p2-tenantrag/src/rag/setup.ts @@ -36,6 +36,7 @@ async function smokeOpenRouter( const openRouter = createOpenRouterClient({ apiKey: config.openRouterApiKey, model: config.openRouterModel, + allowPaid: config.openRouterAllowPaid, timeoutMs: config.providerTimeoutMs, }); const result = await openRouter.generate( diff --git a/p2-tenantrag/src/runtime.ts b/p2-tenantrag/src/runtime.ts index 1c27014..fac49f2 100644 --- a/p2-tenantrag/src/runtime.ts +++ b/p2-tenantrag/src/runtime.ts @@ -30,6 +30,7 @@ export async function createRuntime(config: P2Config) { const openRouter = createOpenRouterClient({ apiKey: config.openRouterApiKey, model: config.openRouterModel, + allowPaid: config.openRouterAllowPaid, timeoutMs: config.providerTimeoutMs, }); return { diff --git a/p2-tenantrag/test/config.test.ts b/p2-tenantrag/test/config.test.ts index 4f3d712..4fd46b5 100644 --- a/p2-tenantrag/test/config.test.ts +++ b/p2-tenantrag/test/config.test.ts @@ -21,6 +21,7 @@ describe("P2 configuration", () => { voyageModel: "voyage-4-lite", voyageDimensions: 256, openRouterModel: "openrouter/free", + openRouterAllowPaid: false, }); expect(config.fixturesDirectory).toBe( resolve("/tutorial/p2-tenantrag", "fixtures"), @@ -39,6 +40,28 @@ describe("P2 configuration", () => { ); }); + it("accepts a selected model only with explicit paid-routing consent", () => { + const config = loadConfig({ + ...validEnvironment, + P2_OPENROUTER_MODEL: "vendor/selected-model", + P2_OPENROUTER_ALLOW_PAID: "true", + }); + expect(config.openRouterModel).toBe("vendor/selected-model"); + expect(config.openRouterAllowPaid).toBe(true); + expect(() => + loadConfig({ ...validEnvironment, P2_OPENROUTER_MODEL: " " }), + ).toThrow("P2_OPENROUTER_MODEL"); + expect(() => + loadConfig({ + ...validEnvironment, + P2_OPENROUTER_MODEL: "vendor/selected-model", + }), + ).toThrow("P2_OPENROUTER_ALLOW_PAID=true"); + expect(() => + loadConfig({ ...validEnvironment, P2_OPENROUTER_ALLOW_PAID: "yes" }), + ).toThrow("P2_OPENROUTER_ALLOW_PAID"); + }); + it("rejects unsafe or invalid configuration", () => { expect(() => loadConfig({ ...validEnvironment, P2_PORT: "0" })).toThrow( "P2_PORT", diff --git a/p2-tenantrag/test/providers.test.ts b/p2-tenantrag/test/providers.test.ts index 1302a12..771adc0 100644 --- a/p2-tenantrag/test/providers.test.ts +++ b/p2-tenantrag/test/providers.test.ts @@ -214,15 +214,40 @@ describe("OpenRouter adapter", () => { model: string; max_completion_tokens: number; reasoning: { effort: string; exclude: boolean }; + provider: { max_price: { prompt: number; completion: number } }; messages: Array<{ content: string }>; }; expect(body.model).toBe("openrouter/free"); expect(body.max_completion_tokens).toBe(512); expect(body.reasoning).toEqual({ effort: "minimal", exclude: true }); + expect(body.provider.max_price).toEqual({ prompt: 0, completion: 0 }); expect(body.messages[1]?.content).toContain("a-public-1"); expect(body.messages[1]?.content).toContain("Synthetic evidence only."); }); + it("removes the zero-price cap only when paid routing is enabled", async () => { + const request = vi.fn().mockResolvedValue( + Response.json({ + model: "vendor/selected-model", + choices: [{ message: { content: "Grounded answer." } }], + }), + ); + const client = createOpenRouterClient({ + apiKey: "test-key", + model: "vendor/selected-model", + allowPaid: true, + timeoutMs: 1_000, + fetch: request, + }); + await client.generate("Question?", [chunk]); + expect(JSON.parse(String(request.mock.calls[0]?.[1]?.body))).toMatchObject({ + model: "vendor/selected-model", + }); + expect( + JSON.parse(String(request.mock.calls[0]?.[1]?.body)), + ).not.toHaveProperty("provider.max_price"); + }); + it("bounds evidence and rejects malformed responses", async () => { const transport = vi .fn() diff --git a/p3-mcp-capability-governance/.env.example b/p3-mcp-capability-governance/.env.example index ba77fb1..598276a 100644 --- a/p3-mcp-capability-governance/.env.example +++ b/p3-mcp-capability-governance/.env.example @@ -4,4 +4,6 @@ P3_MCP_RESOURCE=http://localhost:17003/mcp P3_HOST=127.0.0.1 P3_PORT=17003 P3_OPENROUTER_API_KEY= +P3_OPENROUTER_MODEL=liquid/lfm-2.5-2.6b:free +P3_OPENROUTER_ALLOW_PAID=false P3_PROVIDER_TIMEOUT_MS=15000 diff --git a/p3-mcp-capability-governance/README.md b/p3-mcp-capability-governance/README.md index a600bd5..8f65ef3 100644 --- a/p3-mcp-capability-governance/README.md +++ b/p3-mcp-capability-governance/README.md @@ -50,7 +50,11 @@ pnpm dev `pnpm dev` starts this project’s IdP and application together. For interactive chat, install the project dependencies on the host, set `P3_OPENROUTER_API_KEY` in its `.env`, and run `pnpm chat dana` in another terminal. This client works with either the native or Docker service. -The chat client requests the configured free tool-calling model without a paid fallback; provider availability can vary. The scripted tests reproduce the baseline gap without a provider account. +`P3_OPENROUTER_MODEL` defaults to `liquid/lfm-2.5-2.6b:free`. Select another +OpenRouter model that supports tool calling if needed. Paid routing requires +`P3_OPENROUTER_ALLOW_PAID=true`; without it, the client keeps its zero-price +cap and has no paid fallback. Provider availability can vary. The scripted +tests reproduce the baseline gap without a provider account. For `pnpm build` followed by `pnpm start`, first run `node --env-file=.local/idp/.env ../shared/identity-provider/dist/main.js` in another terminal in this project directory. diff --git a/p3-mcp-capability-governance/package.json b/p3-mcp-capability-governance/package.json index 15e459d..3b3e189 100644 --- a/p3-mcp-capability-governance/package.json +++ b/p3-mcp-capability-governance/package.json @@ -27,6 +27,7 @@ "@modelcontextprotocol/node": "2.0.0", "@modelcontextprotocol/server": "2.0.0", "express": "5.2.1", + "hono": "4.13.7", "jose": "6.2.10", "zod": "4.6.5" }, diff --git a/p3-mcp-capability-governance/pnpm-lock.yaml b/p3-mcp-capability-governance/pnpm-lock.yaml index 63f4c8c..e1219d4 100644 --- a/p3-mcp-capability-governance/pnpm-lock.yaml +++ b/p3-mcp-capability-governance/pnpm-lock.yaml @@ -16,13 +16,16 @@ importers: version: 2.0.0(@modelcontextprotocol/server@2.0.0)(express@5.2.1) '@modelcontextprotocol/node': specifier: 2.0.0 - version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.5) + version: 2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7) '@modelcontextprotocol/server': specifier: 2.0.0 version: 2.0.0 express: specifier: 5.2.1 version: 5.2.1 + hono: + specifier: 4.13.7 + version: 4.13.7 jose: specifier: 6.2.10 version: 6.2.10 @@ -845,8 +848,8 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} - hono@4.13.5: - resolution: {integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} hookified@1.15.1: @@ -1497,9 +1500,9 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 - '@hono/node-server@1.19.17(hono@4.13.5)': + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: - hono: 4.13.5 + hono: 4.13.7 '@humanfs/core@0.19.2': dependencies: @@ -1547,12 +1550,12 @@ snapshots: cors: 2.8.6 express: 5.2.1 - '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.5)': + '@modelcontextprotocol/node@2.0.0(@modelcontextprotocol/server@2.0.0)(hono@4.13.7)': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.5) + '@hono/node-server': 1.19.17(hono@4.13.7) '@modelcontextprotocol/server': 2.0.0 optionalDependencies: - hono: 4.13.5 + hono: 4.13.7 '@modelcontextprotocol/server@2.0.0': dependencies: @@ -2136,7 +2139,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.13.5: {} + hono@4.13.7: {} hookified@1.15.1: {} diff --git a/p3-mcp-capability-governance/scripts/chat.ts b/p3-mcp-capability-governance/scripts/chat.ts index 80bfa25..c9291c4 100644 --- a/p3-mcp-capability-governance/scripts/chat.ts +++ b/p3-mcp-capability-governance/scripts/chat.ts @@ -31,6 +31,7 @@ async function main() { model: new OpenRouterChatModel({ apiKey: config.openRouterApiKey, model: config.openRouterModel, + allowPaid: config.openRouterAllowPaid, timeoutMs: config.providerTimeoutMs, }), mcp, diff --git a/p3-mcp-capability-governance/src/chat/errors.ts b/p3-mcp-capability-governance/src/chat/errors.ts index 4809317..6966a44 100644 --- a/p3-mcp-capability-governance/src/chat/errors.ts +++ b/p3-mcp-capability-governance/src/chat/errors.ts @@ -7,7 +7,7 @@ const messages = { provider_quota: "OpenRouter quota or rate limit reached. Check your account and retry later.", provider_unavailable: - "The configured free model is unavailable. Retry later.", + "The configured model is unavailable. Retry later or choose another model.", provider_timeout: "OpenRouter timed out. No MCP operation was requested; retry later.", provider_network: diff --git a/p3-mcp-capability-governance/src/chat/openrouter.ts b/p3-mcp-capability-governance/src/chat/openrouter.ts index a3a4ff2..a05a76e 100644 --- a/p3-mcp-capability-governance/src/chat/openrouter.ts +++ b/p3-mcp-capability-governance/src/chat/openrouter.ts @@ -40,7 +40,8 @@ const responseSchema = z.object({ type OpenRouterOptions = Readonly<{ apiKey: string; - model: "liquid/lfm-2.5-2.6b:free"; + model: string; + allowPaid?: boolean; timeoutMs: number; fetch?: typeof fetch; }>; @@ -98,7 +99,9 @@ export class OpenRouterChatModel implements ChatModel { tool_choice: "auto", provider: { require_parameters: true, - max_price: { prompt: 0, completion: 0 }, + ...(!this.#options.allowPaid + ? { max_price: { prompt: 0, completion: 0 } } + : {}), }, }), signal: AbortSignal.timeout(this.#options.timeoutMs), diff --git a/p3-mcp-capability-governance/src/config/project-config.ts b/p3-mcp-capability-governance/src/config/project-config.ts index 367be45..4ca8842 100644 --- a/p3-mcp-capability-governance/src/config/project-config.ts +++ b/p3-mcp-capability-governance/src/config/project-config.ts @@ -7,7 +7,8 @@ export type P3Config = Readonly<{ clientId: string; mcpResource: string; openRouterApiKey?: string; - openRouterModel: "liquid/lfm-2.5-2.6b:free"; + openRouterModel: string; + openRouterAllowPaid: boolean; providerTimeoutMs: number; }>; @@ -47,7 +48,29 @@ function integer( return parsed; } +function openRouterModel(env: NodeJS.ProcessEnv, allowPaid: boolean): string { + const model = (env.P3_OPENROUTER_MODEL ?? "liquid/lfm-2.5-2.6b:free").trim(); + if (!model || /\s/.test(model)) { + throw new Error("P3_OPENROUTER_MODEL must be a model ID without spaces"); + } + if (!allowPaid && model !== "openrouter/free" && !model.endsWith(":free")) { + throw new Error( + "P3_OPENROUTER_ALLOW_PAID=true is required for a paid model", + ); + } + return model; +} + +function allowPaid(env: NodeJS.ProcessEnv): boolean { + const value = env.P3_OPENROUTER_ALLOW_PAID?.trim() ?? "false"; + if (value !== "true" && value !== "false") { + throw new Error("P3_OPENROUTER_ALLOW_PAID must be true or false"); + } + return value === "true"; +} + export function loadConfig(env: NodeJS.ProcessEnv = process.env): P3Config { + const openRouterAllowPaid = allowPaid(env); return { host: env.P3_HOST?.trim() || "127.0.0.1", port: integer(env.P3_PORT, 17003, "P3_PORT", 1, 65_535), @@ -60,7 +83,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): P3Config { ...(env.P3_OPENROUTER_API_KEY?.trim() ? { openRouterApiKey: env.P3_OPENROUTER_API_KEY.trim() } : {}), - openRouterModel: "liquid/lfm-2.5-2.6b:free", + openRouterModel: openRouterModel(env, openRouterAllowPaid), + openRouterAllowPaid, providerTimeoutMs: integer( env.P3_PROVIDER_TIMEOUT_MS, 15_000, diff --git a/p3-mcp-capability-governance/test/config.test.ts b/p3-mcp-capability-governance/test/config.test.ts index c1c2dd3..f8bff7f 100644 --- a/p3-mcp-capability-governance/test/config.test.ts +++ b/p3-mcp-capability-governance/test/config.test.ts @@ -14,6 +14,26 @@ describe("P3 configuration", () => { expect(config.issuer).toBe("http://localhost:18003"); expect(config.mcpResource).toBe("http://localhost:17003/mcp"); expect(config.openRouterApiKey).toBeUndefined(); + expect(config.openRouterModel).toBe("liquid/lfm-2.5-2.6b:free"); + expect(config.openRouterAllowPaid).toBe(false); + }); + + it("allows an alternate model with an explicit paid-routing switch", () => { + const config = loadConfig({ + P3_OPENROUTER_MODEL: "vendor/selected-model", + P3_OPENROUTER_ALLOW_PAID: "true", + }); + expect(config.openRouterModel).toBe("vendor/selected-model"); + expect(config.openRouterAllowPaid).toBe(true); + expect(() => loadConfig({ P3_OPENROUTER_MODEL: " " })).toThrow( + "P3_OPENROUTER_MODEL", + ); + expect(() => + loadConfig({ P3_OPENROUTER_MODEL: "vendor/selected-model" }), + ).toThrow("P3_OPENROUTER_ALLOW_PAID=true"); + expect(() => loadConfig({ P3_OPENROUTER_ALLOW_PAID: "yes" })).toThrow( + "P3_OPENROUTER_ALLOW_PAID", + ); }); it("rejects invalid ports and resource URI fragments", () => { diff --git a/p3-mcp-capability-governance/test/openrouter.test.ts b/p3-mcp-capability-governance/test/openrouter.test.ts index 978430b..3aa2b1d 100644 --- a/p3-mcp-capability-governance/test/openrouter.test.ts +++ b/p3-mcp-capability-governance/test/openrouter.test.ts @@ -201,6 +201,29 @@ describe("OpenRouter adapter", () => { }); }); + it("uses the selected paid model only when explicitly enabled", async () => { + let requestBody: unknown; + const model = new OpenRouterChatModel({ + apiKey: "test-key", + model: "vendor/selected-model", + allowPaid: true, + timeoutMs: 1_000, + fetch: async (_input, init) => { + if (typeof init?.body !== "string") throw new Error("Missing body"); + requestBody = JSON.parse(init.body); + return Response.json({ + choices: [{ message: { content: "No action." } }], + }); + }, + }); + await model.next(messages, tools); + expect(requestBody).toMatchObject({ + model: "vendor/selected-model", + provider: { require_parameters: true }, + }); + expect(requestBody).not.toHaveProperty("provider.max_price"); + }); + it.each([[undefined], [null], [[]]])( "accepts a text-only response with tool_calls=%j as no operation", async (toolCalls) => {