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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,8 @@ ELECTRIC_URL=http://localhost:3473
# OPENROUTER_API_KEY= # required when the provider is openrouter
# MAPLE_TRIAGE_MODEL_OPENROUTER=openai/gpt-5.6-luna
# MAPLE_TRIAGE_MODEL_WORKERS_AI=@cf/moonshotai/kimi-k2.6

# Decision model (TypeSafe's Jev): bounded choice/rating/probability answers, served by OpenRouter
# from its own decisions endpoint on the OPENROUTER_API_KEY above. Pin with typesafe/jev-1.13.
# MAPLE_DECISION_MODEL=~typesafe/jev-latest
INTERNAL_SERVICE_TOKEN=dev-internal-service-token
11 changes: 7 additions & 4 deletions apps/ai/src/chat/ChatSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,7 +719,10 @@ export class ChatSessionObject extends Cloudflare.DurableObject<ChatSessionObjec
) {}

/** The activation, as the layer the host Worker provides. */
// `<never>` pinned: the activation's requirements are all `DurableObjectServices`,
// which `.make` already discharges, but inference otherwise widens them into the
// layer's own requirements and they surface all the way up in `alchemy.run.ts`.
export const ChatSessionLive = ChatSessionObject.make<never>(activateChatSession)
// The activation's requirements are named rather than inferred: `.make` discharges
// `DurableObjectServices` (both of these) through its own `Exclude`, while inference
// would widen them into the layer's requirements and surface them all the way up in
// `alchemy.run.ts`.
export const ChatSessionLive = ChatSessionObject.make<
Cloudflare.DurableObjectState | Cloudflare.WorkerEnvironment
>(activateChatSession)
5 changes: 4 additions & 1 deletion apps/ai/src/chat/turn-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis
{ InvestigationServicesLive },
{ layerPg },
{ mapleDbConnectionLayer },
{ layerLlm, resolveTriageModel },
{ layerDecisionModel, layerLlm, resolveTriageModel },
{ buildDiagnosisCompletion },
{ McpToolExecutor },
] = await Promise.all([
Expand All @@ -257,6 +257,9 @@ export const runChatSessionTurn = async (input: RunChatSessionTurnInput): Promis

const runtime = ManagedRuntime.make(
InvestigationServicesLive.pipe(
// Decisions before the clients: `layerLlm` is what answers the OpenRouter
// client the decision model runs on.
Layer.provideMerge(layerDecisionModel(input.env)),
Layer.provideMerge(layerLlm(input.env)),
Layer.provideMerge(layerPg),
Layer.provideMerge(mapleDbConnectionLayer(input.env)),
Expand Down
20 changes: 16 additions & 4 deletions apps/ai/src/mcp/tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,20 @@ class McpDecodeError extends Schema.TaggedError<McpDecodeError>()("@maple/mcp/de
}

/**
* Effect emits exactly `{ anyOf: [{ type: "object" }, { type: "array" }] }` — no
* `type`, no `properties` — for an empty `Struct({})`. Matched structurally so
* the normalization below cannot swallow any other rootless schema.
* Effect emits a rootless schema for an empty `Struct({})` — `{ not: { type:
* "null" } }` since rc.116, `{ anyOf: [{ type: "object" }, { type: "array" }] }`
* before it. Both are matched structurally, so the normalization below cannot
* swallow any other rootless schema.
*/
const isEmptyStructSchema = (base: Record<string, unknown>): boolean => {
if ("type" in base || "properties" in base) return false
const not = base.not
if (typeof not === "object" && not !== null) {
const keys = Object.keys(base).filter((key) => key !== "$defs")
if (keys.length === 1 && (not as { type?: unknown }).type === "null") {
return Object.keys(not).length === 1
}
}
const anyOf = base.anyOf
if (!Array.isArray(anyOf) || anyOf.length === 0) return false
return anyOf.every((member) => {
Expand Down Expand Up @@ -150,7 +158,11 @@ const collapseNullableUnions = (node: unknown): unknown => {
}

export const toInputSchema = (schema: Schema.Top): Record<string, unknown> => {
const document = Schema.toJsonSchemaDocument(schema)
// `onExcessProperty: "error"` keeps `additionalProperties: false` on every
// published tool. rc.116 made the emitted value follow this option and
// defaults it to the decoder's behaviour, which would have loosened the
// schema all 57 public MCP tools advertise.
const document = Schema.toJsonSchemaDocument(schema, { onExcessProperty: "error" })
const rawBase =
Object.keys(document.definitions).length > 0
? { ...document.schema, $defs: document.definitions }
Expand Down
2 changes: 1 addition & 1 deletion apps/ai/src/mcp/transport/stateless-http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export const layerStatelessMcpHttp = (options: {
return jsonRpcError(null, -32700, "Parse error")
}
const messages = decoded.value
if (messages.length > 1 && !options.protocol.transport.acceptsJsonRpcBatches) {
if (messages.length > 1 && !options.protocol.runtime.transport.jsonRpc.acceptsBatches) {
return HttpServerResponse.empty({ status: 400 })
}

Expand Down
96 changes: 93 additions & 3 deletions apps/ai/src/platform/Llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,19 @@
* keeps the run to a single request with no backoff; the resulting failure is expected and ignored.
*/
import { Effect, Layer, Schema, Stream } from "effect"
import { LanguageModel, Tool, Toolkit } from "effect/unstable/ai"
import { Decision, DecisionModel, LanguageModel, Tool, Toolkit } from "effect/unstable/ai"
import { FetchHttpClient } from "effect/unstable/http"
import { describe, it } from "@effect/vitest"
import { assert, describe, it } from "@effect/vitest"
import { expect } from "vitest"
import { layerLlm, resolveTriageModel, type LlmCallTags, type LlmEnv, type ResolvedModel } from "./Llm"
import {
DEFAULT_DECISION_MODEL,
layerDecisionModel,
layerLlm,
resolveTriageModel,
type LlmCallTags,
type LlmEnv,
type ResolvedModel,
} from "./Llm"

interface CapturedRequest {
readonly url: string
Expand Down Expand Up @@ -547,3 +555,85 @@ describe("streamed completion — a stream that ends without a usage block", ()
}),
)
})

/**
* The decision model's own transport check.
*
* Jev is served from a different endpoint than chat completions, and the only honest way to check
* that the model id, the questions and the OpenRouter credential all reach it is to watch what
* leaves.
*/
describe("layerDecisionModel — Jev over OpenRouter", () => {
const ticket = Decision.make({
input: Schema.Struct({ message: Schema.String }),
decisions: {
department: Decision.classify({
instructions: "Which team owns this ticket",
criteria: { billing: "Charges and refunds", technical: "Bugs and outages" },
}),
},
})

const captureDecision = (env: LlmEnv): Effect.Effect<CapturedRequest> =>
Effect.gen(function* () {
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
})
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 })
}

yield* DecisionModel.decide(ticket, { input: { message: "refund me" } }).pipe(
Effect.ignore,
Effect.provide(Layer.provide(layerDecisionModel(env), layerLlm(env))),
Effect.provideService(FetchHttpClient.Fetch, fakeFetch),
)

if (captured === undefined) return yield* Effect.die("no request reached the transport")
return captured
})

it.live("posts the configured model and the decision's questions to the decisions endpoint", () =>
Effect.gen(function* () {
const captured = yield* captureDecision(openRouterEnv)

// Not `/v1/decisions`: the alpha endpoint sits beside the versioned API, not under it.
assert.strictEqual(captured.url, "https://openrouter.ai/api/alpha/decisions")
assert.strictEqual(captured.headers.authorization, "Bearer test-key")
assert.strictEqual(captured.body.model, DEFAULT_DECISION_MODEL)
assert.deepStrictEqual(captured.body.questions, {
department: {
type: "choice",
instructions: "Which team owns this ticket",
criteria: { billing: "Charges and refunds", technical: "Bugs and outages" },
},
})
}))

it.live("carries the same app attribution as a model call", () =>
Effect.gen(function* () {
const captured = yield* captureDecision(openRouterEnv)

assert.strictEqual(captured.headers["http-referer"], "https://maple.dev")
assert.strictEqual(captured.headers["x-title"], "Maple")
}))

it.live("takes the model id from the environment", () =>
Effect.gen(function* () {
const captured = yield* captureDecision({
...openRouterEnv,
MAPLE_DECISION_MODEL: "typesafe/jev-1.13",
})

assert.strictEqual(captured.body.model, "typesafe/jev-1.13")
}))
})
34 changes: 32 additions & 2 deletions apps/ai/src/platform/Llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
* `/chat/completions`, which is what lets one shim serve the binding path.
*/
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai-compat"
import { OpenRouterClient, OpenRouterLanguageModel } from "@effect/ai-openrouter"
import { OpenRouterClient, OpenRouterDecisionModel, OpenRouterLanguageModel } from "@effect/ai-openrouter"
import { MAPLE_NATIVE_SESSION_ID_ATTR, MAPLE_NATIVE_TURN_ID_ATTR } from "@maple/domain/gen-ai"
import { Effect, Layer, Option, Redacted, Schema } from "effect"
import type * as DecisionModel from "effect/unstable/ai/DecisionModel"
import * as LanguageModel from "effect/unstable/ai/LanguageModel"
import * as AiModel from "effect/unstable/ai/Model"
import { FetchHttpClient, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
Expand All @@ -23,6 +24,18 @@ import { layerWorkersAi } from "./WorkersAiHttpClient"
/** Default triage/chat model on OpenRouter — the provider agents run on by default. */
export const DEFAULT_OPENROUTER_MODEL = "z-ai/glm-5.3-flash:nitro"

/**
* Default decision model: TypeSafe's Jev, reached through OpenRouter.
*
* A decision model answers a bounded question — pick one of these labels, rate this, how likely is
* that — and returns the whole distribution. It is not a language model and does not replace one:
* it is what a gate should ask when the answer is a choice rather than prose.
*
* The `~` alias tracks the latest Jev; `typesafe/jev-1.13` pins one, through
* `MAPLE_DECISION_MODEL`, if a gate ever needs a fixed judge.
*/
export const DEFAULT_DECISION_MODEL = "~typesafe/jev-latest"

/**
* 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
Expand Down Expand Up @@ -100,6 +113,8 @@ export interface LlmEnv extends Record<string, unknown> {
/** `low` | `medium` | `high` | `off`. See {@link ReasoningEffort}. */
readonly MAPLE_TRIAGE_REASONING_EFFORT?: string
readonly OPENROUTER_API_KEY?: string
/** Decision model id, overriding {@link DEFAULT_DECISION_MODEL}. */
readonly MAPLE_DECISION_MODEL?: string
}

/**
Expand Down Expand Up @@ -264,7 +279,7 @@ export const agentSessionSpanAttributes = (
*/
const instrumentedModel = <R>(
name: string,
make: Effect.Effect<LanguageModel.Service, never, R>,
make: Effect.Effect<LanguageModel.LanguageModel, never, R>,
telemetry: ModelCallTelemetry,
): Layer.Layer<ModelServices, never, R> =>
AiModel.make(
Expand Down Expand Up @@ -415,3 +430,18 @@ export const layerLlm = (env: LlmEnv): Layer.Layer<LlmClients> => {
}).pipe(Layer.provide(http)),
)
}

/**
* The decision model, over the same OpenRouter client as everything else.
*
* OpenRouter serves Jev from a separate endpoint (`/alpha/decisions`, not chat completions), which
* is why this is its own layer rather than another entry in {@link resolveTriageModel}. It is not
* its own provider though: one key, one set of app-attribution headers, and decision spend lands in
* the same account as model spend. `layerLlm` answers the client it requires.
*/
export const layerDecisionModel = (
env: LlmEnv,
): Layer.Layer<DecisionModel.DecisionModel, never, OpenRouterClient.OpenRouterClient> =>
OpenRouterDecisionModel.layer({
model: readString(env, "MAPLE_DECISION_MODEL") ?? DEFAULT_DECISION_MODEL,
})
8 changes: 4 additions & 4 deletions apps/ai/src/platform/genai-spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,13 +364,13 @@ const idleTimeout = (): AiError.AiError =>
* it, as the stream ends, including when it fails.
*/
export const instrumentLanguageModel = <R>(
make: Effect.Effect<LanguageModel.Service, never, R>,
make: Effect.Effect<LanguageModel.LanguageModel, never, R>,
telemetry: ModelCallTelemetry,
): Effect.Effect<LanguageModel.Service, never, R> =>
): Effect.Effect<LanguageModel.LanguageModel, never, R> =>
make.pipe(
Effect.map((service) => ({
...service,
streamText: ((options: Parameters<LanguageModel.Service["streamText"]>[0]) =>
streamText: ((options: Parameters<LanguageModel.LanguageModel["streamText"]>[0]) =>
Stream.unwrap(
Effect.clockWith((clock) =>
Effect.sync(() => {
Expand Down Expand Up @@ -401,7 +401,7 @@ export const instrumentLanguageModel = <R>(
)
}),
),
)) as LanguageModel.Service["streamText"],
)) as LanguageModel.LanguageModel["streamText"],
})),
)

Expand Down
3 changes: 3 additions & 0 deletions apps/ai/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ const configuredEnv = (stage: MapleStage) =>
optionalPlain("MAPLE_TRIAGE_MODEL_OPENROUTER"),
optionalPlain("MAPLE_TRIAGE_MODEL_WORKERS_AI"),
optionalSecret("OPENROUTER_API_KEY"),
// The decision model (Jev) rides the same OpenRouter key, on OpenRouter's
// separate decisions endpoint. See `layerDecisionModel` in `@/platform/Llm`.
optionalPlain("MAPLE_DECISION_MODEL"),
// The chat agent authenticates to `/mcp` as an internal caller.
optionalSecret("INTERNAL_SERVICE_TOKEN"),
// The source and sandbox tools resolve a connected repository through the
Expand Down
24 changes: 12 additions & 12 deletions apps/api/scripts/bench-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ const stripTrailingSlash = (s: string) => s.replace(/\/+$/, "")

export class BenchConfig extends Context.Service<BenchConfig, BenchConfigValues>()("bench/BenchConfig", {
make: Effect.gen(function* () {
const tbHost = yield* Config.option(Config.string("TINYBIRD_HOST"))
const tbToken = yield* Config.option(Config.redacted("TINYBIRD_TOKEN"))
const internalOrgId = yield* Config.string("MAPLE_INTERNAL_ORG_ID").pipe(
const tbHost = yield* Config.option(Config.String("TINYBIRD_HOST"))
const tbToken = yield* Config.option(Config.Redacted("TINYBIRD_TOKEN"))
const internalOrgId = yield* Config.String("MAPLE_INTERNAL_ORG_ID").pipe(
Config.withDefault("internal"),
)

Expand Down Expand Up @@ -435,36 +435,36 @@ const catalogHandler = Effect.fn("bench.catalog")(function* (config: {
const fetchCommand = Command.make(
"fetch",
{
context: Flag.string("context").pipe(
context: Flag.String("context").pipe(
Flag.withDescription("Filter by query.context label"),
Flag.optional,
),
profile: Flag.string("profile").pipe(Flag.withDescription("Filter by query.profile"), Flag.optional),
since: Flag.string("since").pipe(
profile: Flag.String("profile").pipe(Flag.withDescription("Filter by query.profile"), Flag.optional),
since: Flag.String("since").pipe(
Flag.withDescription("Look-back window, e.g. 24h or 7d"),
Flag.withDefault("24h"),
),
top: Flag.integer("top").pipe(
top: Flag.Int("top").pipe(
Flag.withDescription("Number of fingerprints to keep"),
Flag.withDefault(20),
),
out: Flag.string("out").pipe(Flag.withDescription("Output JSON path"), Flag.optional),
org: Flag.string("org").pipe(Flag.withDescription("Source org (default: internal)"), Flag.optional),
out: Flag.String("out").pipe(Flag.withDescription("Output JSON path"), Flag.optional),
org: Flag.String("org").pipe(Flag.withDescription("Source org (default: internal)"), Flag.optional),
},
fetchHandler,
).pipe(Command.withDescription("Mine recent db.query.text spans from production traces into a JSON file"))

const matchFlag = Flag.string("match").pipe(
const matchFlag = Flag.String("match").pipe(
Flag.withDescription("Case ID/context substring filter"),
Flag.optional,
)
const outFlag = Flag.string("out").pipe(Flag.withDescription("Output JSON path"), Flag.optional)
const outFlag = Flag.String("out").pipe(Flag.withDescription("Output JSON path"), Flag.optional)
const catalogCommand = Command.make(
"catalog",
{
match: matchFlag,
out: outFlag,
suite: Flag.string("suite").pipe(
suite: Flag.String("suite").pipe(
Flag.withDescription("TS module exporting a benchmark suite (default export)"),
Flag.optional,
),
Expand Down
11 changes: 8 additions & 3 deletions apps/api/src/routes/v2/dashboards.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,11 +405,16 @@ describe("v2 dashboards over HTTP", () => {
expect(response.status).toBe(400)
expect(response.body.error.type).toBe("invalid_request_error")
expect(response.body.error.code).toBe("parameter_invalid")
// `param` is the whole path, not just its first segment.
// `param` is the whole path, not just its first segment, and the message
// repeats it: that path is what points a caller at the offending widget.
// It no longer names the widget's `id` or echoes the rejected value —
// effect rc.116 reports neither the input nor the actual for a struct or
// array decode failure.
expect(response.body.error.param).toContain("widgets[0]")
expect(response.body.error.param).toContain("fill_nulls")
expect(response.body.error.message).toContain('widget "error-rate"')
expect(response.body.error.message).toContain("true")
expect(response.body.error.message).toContain("widgets[0]")
expect(response.body.error.message).toContain("fill_nulls")
expect(response.body.error.message).toContain("Expected")

await harness.dispose()
})
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/routes/v2/error-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ type V2SchemaBoundaryError =
*
* `param` carries the full JSON path (`widgets[3].display.fill_nulls`), not
* just its first segment, and the message names the enclosing widget when the
* path points inside a `widgets[]` array — the envelope holds one error, so a
* document with several bad fields reports the first and counts the rest.
* failure reports the offending value to resolve it from. The envelope holds one
* error, so a document with several bad fields reports the first and counts the
* rest.
*/
const V2SchemaErrorTransformLive = HttpApiMiddleware.layerSchemaErrorTransform(
V2SchemaErrors,
Expand Down
Loading
Loading