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
6 changes: 5 additions & 1 deletion apps/api/src/chat/turn-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis
const program = Effect.gen(function* () {
const investigations = yield* InvestigationService
const history = input.session.history()
const model = resolveTriageModel(input.env)
const model = resolveTriageModel(input.env, {
surface: "chat",
orgId: tenant.orgId,
sessionId: input.sessionId,
})
// Shared with the turn so `submit_diagnosis` can report what the investigation cost. See
// `TurnUsage` — the tool is invoked mid-turn, so there is no later moment to hand it a total.
const usage = agent.makeTurnUsage()
Expand Down
11 changes: 6 additions & 5 deletions apps/api/src/mcp/__evals__/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import type { LanguageModel } from "ai"

/**
* Default eval model — matches apps/chat-agent's prod default
* (`DEFAULT_MODEL_ID` in apps/chat-agent/src/lib/openrouter.ts), so evals
* reflect what real users run. Override with `MCP_EVAL_MODEL`.
* Default eval model. Override with `MCP_EVAL_MODEL`.
*/
const DEFAULT_EVAL_MODEL = "moonshotai/kimi-k2.7-code"

Expand All @@ -14,8 +12,11 @@ const evalModelId = (): string => process.env.MCP_EVAL_MODEL ?? DEFAULT_EVAL_MOD
export const hasEvalCredentials = (): boolean => Boolean(process.env.OPENROUTER_API_KEY)

/**
* Build the eval model via OpenRouter — same wiring as
* apps/chat-agent/src/lib/openrouter.ts `createChatModel`.
* Build the eval model via OpenRouter.
*
* Deliberately *not* app-attributed or tagged the way `apps/api/src/platform/Llm.ts` is: this is
* CI-only traffic, and keeping it off Maple's OpenRouter app page keeps eval spend out of the
* product's numbers. See `docs/openrouter-tracing.md`.
*/
export const createEvalModel = (): LanguageModel => {
const apiKey = process.env.OPENROUTER_API_KEY
Expand Down
115 changes: 115 additions & 0 deletions apps/api/src/platform/Llm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Wire-level proof that OpenRouter calls are attributed and tagged.
*
* `LLMClient.prepare` is deliberately not used here: it returns the *protocol* body, which is built
* before `http.body` is overlaid onto it, so it cannot see the tags at all. The only place the tags
* and the attribution headers exist together is the outgoing HTTP request — so the test swaps
* `FetchHttpClient.Fetch` for a capture and reads what would have gone over the wire.
*
* The fake responds 400, which `@maple/llm` classifies as non-retryable. That keeps the run to a
* single request with no backoff; the resulting failure is expected and ignored.
*/
import { LLM } from "@maple/llm"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { describe, expect, it } from "vitest"
import { layerLlm, resolveTriageModel, type LlmCallTags, type LlmEnv } from "./Llm"

interface CapturedRequest {
readonly url: string
readonly headers: Record<string, string>
readonly body: Record<string, unknown>
}

/**
* Run one `LLM.generate` against a fetch that records the request instead of sending it.
*/
const captureRequest = async (env: LlmEnv, tags?: LlmCallTags): Promise<CapturedRequest> => {
let captured: CapturedRequest | undefined

const fakeFetch: typeof globalThis.fetch = async (input, init) => {
const headers: Record<string, string> = {}
new Headers(init?.headers).forEach((value, key) => {
headers[key.toLowerCase()] = value
})
// The body arrives as bytes, not a string — `Response` is the cheapest correct decoder.
const bodyText = await new Response(init?.body ?? "{}").text()
captured = {
url: String(input),
headers,
body: JSON.parse(bodyText) as Record<string, unknown>,
}
return new Response(JSON.stringify({ error: "captured" }), { status: 400 })
}

const request = LLM.request({
model: resolveTriageModel(env, tags),
system: "You are concise.",
prompt: "hi",
})

await Effect.runPromise(
LLM.generate(request).pipe(
Effect.ignore,
Effect.provide(
layerLlm(env).pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fakeFetch))),
),
),
)

if (captured === undefined) throw new Error("no request reached the transport")
return captured
}

const openRouterEnv: LlmEnv = { OPENROUTER_API_KEY: "test-key" }

const tags: LlmCallTags = { surface: "chat", orgId: "org_123", sessionId: "chat_abc" }

describe("resolveTriageModel — OpenRouter attribution", () => {
it("sends the app-attribution headers on every OpenRouter call", async () => {
const captured = await captureRequest(openRouterEnv)

expect(captured.url).toContain("openrouter.ai")
// `HTTP-Referer` is what creates the app page — a title alone does nothing.
expect(captured.headers["http-referer"]).toBe("https://maple.dev")
expect(captured.headers["x-title"]).toBe("Maple")
})

it("tags the request body with surface, org and session", async () => {
const captured = await captureRequest(openRouterEnv, tags)

expect(captured.body).toMatchObject({
user: "org_123",
session_id: "chat_abc",
trace: { trace_name: "chat" },
})
})

it("omits session_id when the caller has no session to group by", async () => {
const captured = await captureRequest(openRouterEnv, { surface: "ai-triage", orgId: "org_123" })

expect(captured.body).toMatchObject({ user: "org_123", trace: { trace_name: "ai-triage" } })
expect(captured.body).not.toHaveProperty("session_id")
})

it("truncates an over-long session id to OpenRouter's 256-character limit", async () => {
const captured = await captureRequest(openRouterEnv, { ...tags, sessionId: "s".repeat(400) })

expect(captured.body.session_id).toHaveLength(256)
})

it("keeps the headers and tags off the Workers AI path", async () => {
const captured = await captureRequest(
{ MAPLE_LLM_PROVIDER: "workers-ai", CLOUDFLARE_API_KEY: "test-key" },
tags,
)

expect(captured.url).not.toContain("openrouter.ai")
expect(captured.headers).not.toHaveProperty("http-referer")
expect(captured.headers).not.toHaveProperty("x-title")
// These are OpenRouter's body fields; Cloudflare must never be sent them.
expect(captured.body).not.toHaveProperty("user")
expect(captured.body).not.toHaveProperty("session_id")
expect(captured.body).not.toHaveProperty("trace")
})
})
48 changes: 46 additions & 2 deletions apps/api/src/platform/Llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,40 @@ import { layerWorkersAi } from "./WorkersAiHttpClient"
*/
export const DEFAULT_OPENROUTER_MODEL = "openai/gpt-5.6-luna"

/**
* OpenRouter app attribution. `HTTP-Referer` is the header that actually creates the app page —
* a title on its own does nothing — so both are sent together or not at all. One URL for every
* surface on purpose: a second referer would mint a second app entry and split the rankings.
*/
const OPENROUTER_APP_URL = "https://maple.dev"
const OPENROUTER_APP_TITLE = "Maple"

/**
* Where a model call came from and what it is running for.
*
* OpenRouter surfaces these three in different places, which is why all three are worth sending:
* `user` shows up on the activity page and in usage exports, `session_id` groups a conversation
* (and makes OpenRouter route the whole session to one provider, so prompt caches actually hit),
* and `trace` is forwarded to any configured Broadcast destination.
*/
export interface LlmCallTags {
readonly surface: "chat" | "ai-triage"
readonly orgId: string
/** Groups one conversation or investigation. OpenRouter caps this at 256 characters. */
readonly sessionId?: string
}

/**
* The tag fields as OpenRouter's request body wants them. Kept next to the `configure` call that
* uses it because these are OpenRouter's field names, not Maple's — the Workers AI branch must
* never see them.
*/
const openRouterTagBody = (tags: LlmCallTags) => ({
user: tags.orgId,
...(tags.sessionId === undefined ? {} : { session_id: tags.sessionId.slice(0, 256) }),
trace: { trace_name: tags.surface },
})

/**
* Default triage/chat model on Workers AI. Carried over unchanged from the pre-`@maple/llm` chat
* backend (`cloudflare/@cf/moonshotai/kimi-k2.6`, minus that runtime's `provider/` prefix), so the
Expand Down Expand Up @@ -82,15 +116,25 @@ export const resolveLlmProvider = (env: LlmEnv): LlmProvider =>
? "workers-ai"
: DEFAULT_LLM_PROVIDER

/** Resolve the model the triage/chat agents should run on, from env. */
export const resolveTriageModel = (env: LlmEnv): Model =>
/**
* Resolve the model the triage/chat agents should run on, from env.
*
* `tags` is optional and OpenRouter-only. Attribution headers ride on every OpenRouter call
* regardless; the per-call tags are folded into the request body as route defaults, so every
* `LLM.request`/`generate`/`stream` made with the returned model carries them without each call
* site having to thread them through. The Workers AI branch deliberately ignores `tags` — they are
* OpenRouter's body fields and have no meaning to Cloudflare.
*/
export const resolveTriageModel = (env: LlmEnv, tags?: LlmCallTags): Model =>
resolveLlmProvider(env) === "workers-ai"
? CloudflareWorkersAI.configure({
accountId: readString(env, "CLOUDFLARE_ACCOUNT_ID") ?? BINDING_PLACEHOLDER,
apiKey: readString(env, "CLOUDFLARE_API_KEY") ?? BINDING_PLACEHOLDER,
}).model(readString(env, "MAPLE_TRIAGE_MODEL_WORKERS_AI") ?? DEFAULT_WORKERS_AI_MODEL)
: OpenRouter.configure({
apiKey: readString(env, "OPENROUTER_API_KEY") ?? "",
headers: { "HTTP-Referer": OPENROUTER_APP_URL, "X-Title": OPENROUTER_APP_TITLE },
...(tags === undefined ? {} : { http: { body: openRouterTagBody(tags) } }),
}).model(readString(env, "MAPLE_TRIAGE_MODEL_OPENROUTER") ?? DEFAULT_OPENROUTER_MODEL)

/**
Expand Down
7 changes: 6 additions & 1 deletion apps/api/src/workflows/AiTriageWorkflow.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ const invokeTriageWorkflow = async ({
env,
orgId,
incidentKind,
incidentId,
context,
}: InvokeTriageInput): Promise<TriageInvocationResult> => {
if (!canReachModel(env)) throw new Error("llm_unavailable")
Expand Down Expand Up @@ -127,7 +128,11 @@ const invokeTriageWorkflow = async ({
orgId,
incidentKind,
context,
model: resolveTriageModel(env),
model: resolveTriageModel(env, {
surface: "ai-triage",
orgId,
sessionId: `triage_${incidentKind}_${incidentId}`,
}),
tenant: {
orgId: decodeOrgId(orgId),
userId: internalServiceUserId,
Expand Down
9 changes: 9 additions & 0 deletions apps/slack-agent/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@ import { defineAgent } from "eve"

/**
* OpenRouter over its REST API.
*
* `appUrl`/`appName` set `HTTP-Referer`/`X-OpenRouter-Title`, which is what attributes this
* traffic to Maple's app page on openrouter.ai. Same URL and title as `apps/api` on purpose: the
* referer is the app's identity, so a different one here would mint a second app entry and split
* the rankings. Surfaces are told apart by `trace.trace_name` instead — static, because this
* process only ever is the Slack agent.
*/
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY ?? "",
appUrl: "https://maple.dev",
appName: "Maple",
extraBody: { trace: { trace_name: "slack" } },
})

/**
Expand Down
Loading
Loading