Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ EMBEDDED_AGENT_PROVIDER= # Required when multiple provider keys are set: 'op
OPENAI_API_KEY= # Required if using OpenAI
ANTHROPIC_API_KEY= # Required if using Anthropic
OPENROUTER_API_KEY= # Required if using OpenRouter
OPENROUTER_MODEL= # Optional OpenRouter model, defaults to 'openai/gpt-5'
OPENROUTER_MODEL= # Optional OpenRouter model, defaults to 'openai/gpt-5.6-luna'
OPENROUTER_REASONING_EFFORT= # Optional OpenRouter reasoning effort, defaults to 'medium'

# Optional overrides
SENTRY_HOST= # For self-hosted deployments
Expand Down
8 changes: 5 additions & 3 deletions docs/operations/embedded-agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ For OpenRouter:

```bash
export OPENROUTER_API_KEY=sk-or-...
# Optional; defaults to openai/gpt-5
export OPENROUTER_MODEL=openai/gpt-5
# Optional; defaults to openai/gpt-5.6-luna with reasoning effort medium
export OPENROUTER_MODEL=openai/gpt-5.6-luna
export OPENROUTER_REASONING_EFFORT=medium
# Recommended, and required when multiple provider keys are set
export EMBEDDED_AGENT_PROVIDER=openrouter
```
Expand Down Expand Up @@ -219,8 +220,9 @@ export OPENAI_MODEL=gpt-4
# Anthropic (default: claude-opus-4-5-20251101)
export ANTHROPIC_MODEL=claude-sonnet-4-5-20250929

# OpenRouter (default: openai/gpt-5)
# OpenRouter (default: openai/gpt-5.6-luna @ medium)
export OPENROUTER_MODEL=anthropic/claude-sonnet-4
export OPENROUTER_REASONING_EFFORT=high
```

### Verify Configuration
Expand Down
1 change: 0 additions & 1 deletion packages/mcp-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@
},
"dependencies": {
"@ai-sdk/mcp": "catalog:",
"@ai-sdk/openai": "catalog:",
"@ai-sdk/react": "catalog:",
"@cloudflare/workers-oauth-provider": "catalog:",
"@modelcontextprotocol/sdk": "catalog:",
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp-cloudflare/src/server/lib/mcp-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ function createTestEnv(): Env {
SENTRY_CLIENT_ID: "test-client-id",
SENTRY_CLIENT_SECRET: "test-client-secret",
SENTRY_HOST: "sentry.io",
OPENAI_API_KEY: "test-openai-key",
OPENROUTER_API_KEY: "test-openrouter-key",
OPENROUTER_MODEL: "openai/gpt-5.6-luna",
EMBEDDED_AGENT_PROVIDER: "openrouter",
OAUTH_KV: {} as KVNamespace,
OAUTH_PROVIDER: {
listUserGrants: vi.fn().mockResolvedValue({ items: [] }),
Expand Down
19 changes: 13 additions & 6 deletions packages/mcp-cloudflare/src/server/routes/chat.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { experimental_createMCPClient } from "@ai-sdk/mcp";
import { openai } from "@ai-sdk/openai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import {
getOpenRouterModel,
getOpenRouterProviderOptions,
} from "@sentry/mcp-core/internal/agents/openrouter-provider";
import { logInfo, logIssue, logWarn } from "@sentry/mcp-core/telem/logging";
import {
convertToModelMessages,
Expand Down Expand Up @@ -93,9 +96,10 @@ async function refreshTokenIfNeeded(
}

export default new Hono<{ Bindings: Env }>().post("/", async (c) => {
// Validate that we have an OpenAI API key
if (!c.env.OPENAI_API_KEY) {
logIssue("OPENAI_API_KEY is not configured", {
// Validate that we have an OpenRouter API key (process.env is populated from
// worker bindings via nodejs_compat_populate_process_env).
if (!c.env.OPENROUTER_API_KEY && !process.env.OPENROUTER_API_KEY) {
logIssue("OPENROUTER_API_KEY is not configured", {
loggerScope: ["cloudflare", "chat"],
});
return c.json(
Expand Down Expand Up @@ -307,7 +311,7 @@ export default new Hono<{ Bindings: Env }>().post("/", async (c) => {
});

const result = streamText({
model: openai("gpt-4o"),
model: getOpenRouterModel(),
messages: modelMessages,
tools,
system: `You are an AI assistant designed EXCLUSIVELY for testing the Sentry MCP service. Your sole purpose is to help users test MCP functionality with their real Sentry account data - nothing more, nothing less.
Expand All @@ -332,8 +336,11 @@ Start conversations by exploring what's available in their account. Use tools li
- \`get_sentry_resource\` to dive deep into a specific issue, event, or trace

Remember: You're a test assistant, not a general-purpose helper. Stay focused on testing the MCP integration with their real data.`,
maxOutputTokens: 2000,
// Reasoning effort can consume completion budget before visible text/tool
// calls, so keep headroom above the old non-reasoning 2k cap.
maxOutputTokens: 16000,
stopWhen: stepCountIs(10),
providerOptions: getOpenRouterProviderOptions(),
Comment thread
cursor[bot] marked this conversation as resolved.
experimental_telemetry: {
isEnabled: true,
},
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp-cloudflare/src/server/sentry.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ function createEnv(overrides: Partial<Env> = {}): Env {
SENTRY_CLIENT_SECRET: "test-client-secret",
SENTRY_DSN: "https://public@example.ingest.sentry.io/1",
SENTRY_HOST: "sentry.io",
OPENAI_API_KEY: "test-openai-key",
OPENROUTER_API_KEY: "test-openrouter-key",
OPENROUTER_MODEL: "openai/gpt-5.6-luna",
EMBEDDED_AGENT_PROVIDER: "openrouter",
OAUTH_PROVIDER: {} as Env["OAUTH_PROVIDER"],
AI: {} as Ai,
...overrides,
Expand Down
7 changes: 6 additions & 1 deletion packages/mcp-cloudflare/src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ export interface Env {
SENTRY_ENVIRONMENT?: string;
SENTRY_DSN?: string;
SENTRY_HOST?: string;
OPENAI_API_KEY: string;
/** @deprecated Prefer OPENROUTER_API_KEY for hosted MCP AI features. */
OPENAI_API_KEY?: string;
OPENROUTER_API_KEY?: string;
OPENROUTER_MODEL?: string;
OPENROUTER_REASONING_EFFORT?: string;
EMBEDDED_AGENT_PROVIDER?: string;
MCP_URL?: string;
OAUTH_PROVIDER: OAuthHelpers;
AI: Ai;
Expand Down
8 changes: 6 additions & 2 deletions packages/mcp-cloudflare/worker-configuration.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ declare namespace Cloudflare {
SENTRY_CLIENT_ID: string;
SENTRY_CLIENT_SECRET: string;
SENTRY_DSN: string;
OPENAI_API_KEY: string;
OPENAI_API_KEY?: string;
OPENROUTER_API_KEY?: string;
OPENROUTER_MODEL?: string;
OPENROUTER_REASONING_EFFORT?: string;
EMBEDDED_AGENT_PROVIDER?: string;
COOKIE_SECRET: string;
CHAT_RATE_LIMITER: RateLimit;
SEARCH_RATE_LIMITER: RateLimit;
Expand All @@ -24,7 +28,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
};
declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "SENTRY_CLIENT_ID" | "SENTRY_CLIENT_SECRET" | "SENTRY_DSN" | "OPENAI_API_KEY" | "COOKIE_SECRET">> {}
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "SENTRY_CLIENT_ID" | "SENTRY_CLIENT_SECRET" | "SENTRY_DSN" | "OPENAI_API_KEY" | "OPENROUTER_API_KEY" | "OPENROUTER_MODEL" | "OPENROUTER_REASONING_EFFORT" | "EMBEDDED_AGENT_PROVIDER" | "COOKIE_SECRET">> {}
}

// Begin runtime types
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp-cloudflare/wrangler.test.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"SENTRY_CLIENT_ID": "test-client-id",
"SENTRY_CLIENT_SECRET": "test-client-secret",
"SENTRY_HOST": "sentry.io",
"OPENAI_API_KEY": "test-openai-key"
"OPENROUTER_API_KEY": "test-openrouter-key",
"OPENROUTER_MODEL": "openai/gpt-5.6-luna",
"EMBEDDED_AGENT_PROVIDER": "openrouter"
},
"kv_namespaces": [
{
Expand Down
3 changes: 2 additions & 1 deletion packages/mcp-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ MCP_ADD_SCOPES=event:write # Add to default scopes (keeps defaults)
OPENAI_API_KEY=your-openai-key # Use OpenAI for AI-powered search tools
OPENAI_MODEL=gpt-5 # OpenAI model to use (default: "gpt-5")
OPENROUTER_API_KEY=your-openrouter-key # Or use OpenRouter for AI-powered search tools
OPENROUTER_MODEL=openai/gpt-5 # OpenRouter model to use (default: "openai/gpt-5")
OPENROUTER_MODEL=openai/gpt-5.6-luna # OpenRouter model to use (default: "openai/gpt-5.6-luna")
OPENROUTER_REASONING_EFFORT=medium # OpenRouter reasoning effort: "none", "minimal", "low", "medium", "high", "xhigh" ("max" aliases xhigh), or "" to omit (default: "medium")
OPENAI_REASONING_EFFORT=low # Reasoning effort for o1 models: "low", "medium", "high", or "" to disable (default: "low")

# No environment variable exists for the OpenAI base URL override; use --openai-base-url instead.
Expand Down
69 changes: 67 additions & 2 deletions packages/mcp-core/src/internal/agents/openrouter-provider.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import type { LanguageModelV3 } from "@ai-sdk/provider";
import { generateText } from "ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getOpenRouterModel } from "./openrouter-provider.js";
import { ConfigurationError } from "../../errors.js";
import {
getOpenRouterModel,
getOpenRouterProviderOptions,
} from "./openrouter-provider.js";

describe("openrouter-provider", () => {
const originalApiKey = process.env.OPENROUTER_API_KEY;
const originalModel = process.env.OPENROUTER_MODEL;
const originalReasoningEffort = process.env.OPENROUTER_REASONING_EFFORT;

beforeEach(() => {
process.env.OPENROUTER_API_KEY = "test-openrouter-key";
delete process.env.OPENROUTER_MODEL;
delete process.env.OPENROUTER_REASONING_EFFORT;
});

afterEach(() => {
Expand All @@ -25,6 +31,12 @@ describe("openrouter-provider", () => {
process.env.OPENROUTER_MODEL = originalModel;
}

if (originalReasoningEffort === undefined) {
delete process.env.OPENROUTER_REASONING_EFFORT;
} else {
process.env.OPENROUTER_REASONING_EFFORT = originalReasoningEffort;
}

vi.unstubAllGlobals();
});

Expand Down Expand Up @@ -74,7 +86,7 @@ describe("openrouter-provider", () => {

it("uses default and configured models", () => {
expect((getOpenRouterModel() as LanguageModelV3).modelId).toBe(
"openai/gpt-5",
"openai/gpt-5.6-luna",
);

process.env.OPENROUTER_MODEL = "anthropic/claude-sonnet-4";
Expand All @@ -86,4 +98,57 @@ describe("openrouter-provider", () => {
(getOpenRouterModel("google/gemini-2.5-pro") as LanguageModelV3).modelId,
).toBe("google/gemini-2.5-pro");
});

it("defaults reasoning effort to medium and allows override", () => {
expect(getOpenRouterProviderOptions()).toEqual({
openai: {
structuredOutputs: false,
strictJsonSchema: false,
reasoningEffort: "medium",
},
});

process.env.OPENROUTER_REASONING_EFFORT = "high";

expect(getOpenRouterProviderOptions()).toEqual({
openai: {
structuredOutputs: false,
strictJsonSchema: false,
reasoningEffort: "high",
},
});

process.env.OPENROUTER_REASONING_EFFORT = "";

expect(getOpenRouterProviderOptions()).toEqual({
openai: {
structuredOutputs: false,
strictJsonSchema: false,
},
});
});

it("maps max to xhigh and rejects unknown reasoning effort", () => {
process.env.OPENROUTER_REASONING_EFFORT = "max";

expect(getOpenRouterProviderOptions()).toEqual({
openai: {
structuredOutputs: false,
strictJsonSchema: false,
reasoningEffort: "xhigh",
},
});

process.env.OPENROUTER_REASONING_EFFORT = "ludicrous";

expect(() => getOpenRouterProviderOptions()).toThrow(ConfigurationError);
expect(() => getOpenRouterProviderOptions()).toThrow(
/Invalid OPENROUTER_REASONING_EFFORT/,
);

// Prototype keys must not bypass validation via inherited Object props.
process.env.OPENROUTER_REASONING_EFFORT = "constructor";

expect(() => getOpenRouterProviderOptions()).toThrow(ConfigurationError);
});
});
82 changes: 81 additions & 1 deletion packages/mcp-core/src/internal/agents/openrouter-provider.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,64 @@
import { createOpenAI } from "@ai-sdk/openai";
import type { LanguageModel } from "ai";
import { ConfigurationError } from "../../errors";
import { USER_AGENT } from "../../version";
import type { ProviderOptions } from "./types";

const DEFAULT_OPENROUTER_MODEL = "openai/gpt-5";
const DEFAULT_OPENROUTER_MODEL = "openai/gpt-5.6-luna";
const DEFAULT_OPENROUTER_REASONING_EFFORT = "medium";
const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1";

// Matches @ai-sdk/openai chat providerOptions.openai.reasoningEffort.
const OPENROUTER_REASONING_EFFORTS = [
"none",
"minimal",
"low",
"medium",
"high",
"xhigh",
] as const;

type OpenRouterReasoningEffort = (typeof OPENROUTER_REASONING_EFFORTS)[number];

// Null prototype so Object.prototype keys like "constructor" cannot slip through.
const OPENROUTER_REASONING_EFFORT_ALIASES: Record<
string,
OpenRouterReasoningEffort
> = Object.assign(Object.create(null), {
// OpenRouter sometimes documents "max"; AI SDK accepts "xhigh".
max: "xhigh",
});

function resolveOpenRouterReasoningEffort(
value: string | undefined,
): OpenRouterReasoningEffort | undefined {
if (value === undefined) {
return DEFAULT_OPENROUTER_REASONING_EFFORT;
}

// Empty string omits the provider option so the model default applies.
if (value === "") {
return undefined;
}

const normalized = value.trim().toLowerCase();
if (Object.hasOwn(OPENROUTER_REASONING_EFFORT_ALIASES, normalized)) {
return OPENROUTER_REASONING_EFFORT_ALIASES[normalized];
}

if (
(OPENROUTER_REASONING_EFFORTS as readonly string[]).includes(normalized)
) {
return normalized as OpenRouterReasoningEffort;
}

throw new ConfigurationError(
`Invalid OPENROUTER_REASONING_EFFORT "${value}". Expected one of: ${OPENROUTER_REASONING_EFFORTS.join(
", ",
)}, max (alias for xhigh), or "" to omit.`,
);
}

/**
* Builds an OpenRouter chat-completions model for embedded agent calls.
*/
Expand All @@ -23,3 +77,29 @@ export function getOpenRouterModel(model?: string): LanguageModel {
model ?? process.env.OPENROUTER_MODEL ?? DEFAULT_OPENROUTER_MODEL,
);
}

/**
* Provider options for OpenRouter chat-completions calls.
*
* Uses the OpenAI chat provider options shape because OpenRouter is accessed
* through `@ai-sdk/openai` with an OpenRouter base URL.
*/
export function getOpenRouterProviderOptions(): ProviderOptions {
const reasoningEffort = resolveOpenRouterReasoningEffort(
process.env.OPENROUTER_REASONING_EFFORT,
);

return {
openai: {
// Required for optional structured-output fields.
// See: https://github.com/getsentry/sentry-mcp/issues/623
structuredOutputs: false,
strictJsonSchema: false,
...(reasoningEffort
? {
reasoningEffort,
}
: {}),
},
};
}
Comment thread
cursor[bot] marked this conversation as resolved.
Loading
Loading