Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions p2-tenantrag/.env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
P2_VOYAGE_API_KEY=
P2_OPENROUTER_API_KEY=
P2_OPENROUTER_MODEL=openrouter/free
P2_OPENROUTER_ALLOW_PAID=false
5 changes: 5 additions & 0 deletions p2-tenantrag/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion p2-tenantrag/scripts/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.`);
28 changes: 26 additions & 2 deletions p2-tenantrag/src/config/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;

Expand All @@ -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:") {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion p2-tenantrag/src/rag/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type OpenRouterClient = Readonly<{
type OpenRouterClientOptions = Readonly<{
apiKey: string;
model: string;
allowPaid?: boolean;
timeoutMs: number;
fetch?: typeof fetch;
}>;
Expand Down Expand Up @@ -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: [
{
Expand Down
1 change: 1 addition & 0 deletions p2-tenantrag/src/rag/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions p2-tenantrag/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions p2-tenantrag/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions p2-tenantrag/test/providers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof fetch>().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<typeof fetch>()
Expand Down
2 changes: 2 additions & 0 deletions p3-mcp-capability-governance/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion p3-mcp-capability-governance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions p3-mcp-capability-governance/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
21 changes: 12 additions & 9 deletions p3-mcp-capability-governance/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions p3-mcp-capability-governance/scripts/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ async function main() {
model: new OpenRouterChatModel({
apiKey: config.openRouterApiKey,
model: config.openRouterModel,
allowPaid: config.openRouterAllowPaid,
timeoutMs: config.providerTimeoutMs,
}),
mcp,
Expand Down
2 changes: 1 addition & 1 deletion p3-mcp-capability-governance/src/chat/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 5 additions & 2 deletions p3-mcp-capability-governance/src/chat/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;
Expand Down Expand Up @@ -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),
Expand Down
28 changes: 26 additions & 2 deletions p3-mcp-capability-governance/src/config/project-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}>;

Expand Down Expand Up @@ -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),
Expand All @@ -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,
Expand Down
Loading
Loading