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
16 changes: 13 additions & 3 deletions src/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ function outOfTime(opts: AskOptions): Asked {
return { ok: false, error: `the time budget ran out${opts.what ? ` before ${opts.what}` : ""}` };
}

/** Endpoint+model pairs that refused `temperature`; asked without it from then on. */
const noTemperature = new Set<string>();

export async function askModel(opts: AskOptions): Promise<Asked> {
const pauses = opts.overloadBackoffMs ?? ASK_OVERLOAD_BACKOFF_MS;
if ((leftMs(opts.deadlineAt) ?? 1) <= 0) return outOfTime(opts);
Expand Down Expand Up @@ -202,20 +205,27 @@ async function askOnce(opts: AskOptions, maxTokens: number): Promise<Asked> {
{ role: "user", content: opts.prompt },
],
max_tokens: maxTokens,
temperature: 0,
...(noTemperature.has(`${base} ${opts.model}`) ? {} : { temperature: 0 }),
...(opts.reasoningEffort ? { reasoning: { effort: opts.reasoningEffort } } : {}),
...selfHostedThinking(base, opts.reasoningEffort),
...openRouterProvider(base, opts.model),
...(stream ? { stream: true } : {}),
}),
});
if (!res.ok) {
const body = res.status === 429 ? await res.text().catch(() => "") : "";
const body = await res.text().catch(() => "");
// Some current models refuse a sampling setting outright (Claude Haiku 5.5:
// "`temperature` is deprecated for this model"). Remember it and ask again

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: /temperature/i matches any 400 that mentions or echoes temperature, then silently disables temperature: 0 for the rest of the process. Narrow the match (deprecated/unsupported) and record in the judge's receipt that it ran without temperature 0.

// without, rather than leaving every judge call on that model refused.
if (res.status === 400 && /temperature/i.test(body) && !noTemperature.has(`${base} ${opts.model}`)) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this recursion still holds the localgate turn taken above. On a self-hosted base with 1 slot, the inner takeTurn(base) waits forever, because the outer release() only runs in finally after this returns and takeTurn gets no abort signal. Release first (it's idempotent) or retry outside the try/finally, and add a localhost-base test.

noTemperature.add(`${base} ${opts.model}`);
return askOnce(opts, maxTokens);
}
const resetAt = rateLimitResetAt(body);
if (resetAt !== undefined && resetAt - Date.now() > LONG_RATE_LIMIT_MS) {
return { ok: false, error: `the provider's rate limit is reached ${untilText(resetAt)}${opts.what ? ` (${opts.what})` : ""}` };

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: some gateways echo request messages in 400 bodies, and the judge prompt carries the hidden checks. Prefer extracting error.message from JSON over the raw first 300 chars.

}
return { ok: false, error: `HTTP ${res.status}${opts.what ? ` ${opts.what}` : ""}`, ...(res.status === 429 || res.status === 503 ? { transient: true } : {}) };
return { ok: false, error: `HTTP ${res.status}${opts.what ? ` ${opts.what}` : ""}${res.status === 400 && body ? `: ${body.slice(0, 300)}` : ""}`, ...(res.status === 429 || res.status === 503 ? { transient: true } : {}) };
}
type Reply = {
choices?: { message?: { content?: string | null }; finish_reason?: string | null }[];
Expand Down
4 changes: 3 additions & 1 deletion src/judge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export function judgeTarget<T extends Target>(worker: T, env: NodeJS.ProcessEnv
env.MAAT_JUDGE_KEY?.trim() ||
(baseUrl === worker.baseUrl ? worker.apiKey : undefined) ||
// A CLI worker (Grok Build, OpenCode) judged on OpenRouter uses the OpenRouter key.
(/^https:\/\/openrouter\.ai\//.test(baseUrl) ? env.OPENROUTER_API_KEY?.trim() || undefined : undefined);
(/^https:\/\/openrouter\.ai\//.test(baseUrl) ? env.OPENROUTER_API_KEY?.trim() || undefined : undefined) ||
// A judge on Anthropic's API (e.g. a Haiku judge for an OpenRouter worker) uses the Anthropic key.
(/^https:\/\/api\.anthropic\.com\//.test(baseUrl) ? env.ANTHROPIC_API_KEY?.trim() || undefined : undefined);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking per the #33 decision (no competitor names in the repo; provider details live in local bench config). MAAT_JUDGE_KEY above already covers a judge on a different provider, so the bench machine can set it locally and this hardcoded host and env var aren't needed.

const { apiKey: _workerKey, ...rest } = worker;
return { ...rest, baseUrl, model, ...(apiKey !== undefined ? { apiKey } : {}) } as T;
}
Expand Down
31 changes: 31 additions & 0 deletions test/ask-temperature.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { askModel } from "../src/ask.js";

describe("a model that refuses temperature", () => {
it("is asked again without it, and the error body is kept for other 400s", async () => {
const bodies: Record<string, unknown>[] = [];
const fetchFn = (async (_url: string, init: { body: string }) => {
const body = JSON.parse(init.body) as Record<string, unknown>;
bodies.push(body);
if ("temperature" in body) {
return new Response(JSON.stringify({ error: { message: "`temperature` is deprecated for this model." } }), { status: 400 });
}
return new Response(JSON.stringify({ choices: [{ message: { content: "{\"ok\":true}" }, finish_reason: "stop" }] }), { status: 200, headers: { "content-type": "application/json" } });
}) as unknown as typeof fetch;
const r = await askModel({ baseUrl: "https://api.anthropic.com/v1", apiKey: "k", model: "claude-haiku-5-5", system: "s", prompt: "p", fetchFn });
assert.ok(r.ok, JSON.stringify(r));
assert.equal(bodies.length, 2);
assert.ok(!("temperature" in bodies[1]!));
// Remembered: the next ask goes straight out without it.
await askModel({ baseUrl: "https://api.anthropic.com/v1", apiKey: "k", model: "claude-haiku-5-5", system: "s", prompt: "p", fetchFn });
assert.equal(bodies.length, 3);
assert.ok(!("temperature" in bodies[2]!));
});

it("puts a 400's body in the error", async () => {
const fetchFn = (async () => new Response("bad tool_choice value", { status: 400 })) as unknown as typeof fetch;
const r = await askModel({ baseUrl: "https://x.test/v1", model: "m", system: "s", prompt: "p", fetchFn });
assert.ok(!r.ok && /bad tool_choice/.test(r.error));
});
});
11 changes: 11 additions & 0 deletions test/judge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,14 @@ describe("Grok Build as the judge of an HTTP worker", () => {
assert.deepEqual(r, { ok: true, text: '{"criteria":[]}', cutOff: false });
});
});

describe("a judge on Anthropic's API", () => {
it("takes ANTHROPIC_API_KEY when the worker is elsewhere", () => {
const t = judgeTarget(
{ baseUrl: "https://openrouter.ai/api/v1", apiKey: "or-key", model: "deepseek/deepseek-v4-pro" },
{ MAAT_JUDGE_MODEL: "claude-haiku-5-5", MAAT_JUDGE_URL: "https://api.anthropic.com/v1", ANTHROPIC_API_KEY: "sk-ant-x" },
);
assert.equal(t.apiKey, "sk-ant-x");
assert.equal(t.model, "claude-haiku-5-5");
});
});
Loading