Compare commits

..

1 Commits

Author SHA1 Message Date
Xiangzhe
2a60405d79 fix(opencode-plugin): track active release branch in CI + align combo-id fixture
The opencode-plugin CI workflow trigger was pinned to release/v3.8.2 since
its creation, so it stopped firing once the active release line moved on —
the workflow never ran mid-cycle. Switch push/pull_request branch filters
to release/** so it tracks whatever release branch is active.

Also align the provider.test.ts fixture expectations with the combo-id
contract from #10345/#10821: mapRawModelToModelV2 leaves bare combo ids
(owned_by: "combo") unprefixed so OpenCode's `-m <plugin>/<combo>` lookup
resolves them directly. The two assertions still expected the old
provider-prefixed form.

Closes #11291
Closes #11292
2026-08-23 21:10:21 -03:00
5 changed files with 25 additions and 134 deletions

View File

@@ -2,11 +2,11 @@ name: opencode-plugin CI
on:
push:
branches: [main, release/v3.8.2]
branches: [main, "release/**"]
paths:
- "@omniroute/opencode-plugin/**"
pull_request:
branches: [main, release/v3.8.2]
branches: [main, "release/**"]
paths:
- "@omniroute/opencode-plugin/**"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -104,7 +104,10 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
// that prefix must never leak into anything OmniRoute's server parses.
assert.ok(out["omniroute/claude-primary"]);
// #10345/#10821: bare combo ids (owned_by: "combo") stay unprefixed —
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under the
// plugin provider, so `claude-primary` here carries no provider prefix.
assert.ok(out["claude-primary"]);
});
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
@@ -159,11 +162,15 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
// anything OmniRoute's own server parses for credential lookup.
const claude = out["omniroute/claude-primary"];
// #10345/#10821: bare **combo** ids (owned_by: "combo", e.g.
// "claude-primary") must also stay unprefixed — OpenCode looks up
// `-m <plugin>/<combo>` as model id `<combo>` under the plugin provider.
const claude = out["claude-primary"];
assert.ok(claude, "claude-primary present");
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
// static-catalog reader resolves `(providerID, modelID)` from the key.
assert.equal(claude.id, "omniroute/claude-primary");
// `mapRawModelToModelV2` leaves bare combo ids unprefixed (see
// src/index.ts mapRawModelToModelV2) so OC's `-m <plugin>/<combo>` lookup
// resolves the combo id directly.
assert.equal(claude.id, "claude-primary");
assert.equal(claude.name, "claude-primary");
assert.equal(claude.providerID, "omniroute");
assert.equal(claude.api.id, "openai-compatible");

View File

@@ -20,7 +20,6 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts";
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
import {
shouldDefaultAllowClassifier,
detectClassifierFormat,
buildDefaultAllowClaudeMessage,
} from "./chatCore/claudeClassifierCompat.ts";
import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts";
@@ -779,12 +778,11 @@ export async function handleChatCore({
classifierSettings.claudeClassifierCompat as string | undefined
)
) {
const classifierFormat = detectClassifierFormat(body as Record<string, unknown>);
log?.warn?.(
"CHAT",
`classifier compat=${classifierSettings.claudeClassifierCompat} format=${classifierFormat} | short-circuit default-allow`
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
);
return buildDefaultAllowClaudeMessage(requestedModel, classifierFormat);
return buildDefaultAllowClaudeMessage(requestedModel);
}
}

View File

@@ -24,19 +24,14 @@ const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI co
export type ClaudeClassifierCompatMode = "off" | "auto" | "always";
/** The two synthetic-response shapes Claude Code's classifier can expect. */
export type ClaudeClassifierFormat = "block" | "severity";
function extractSystemTexts(body: Record<string, unknown> | null | undefined): string[] {
const system = body?.system;
if (typeof system === "string") return [system];
if (Array.isArray(system)) {
return system
.map((part) =>
part && typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text
: ""
)
.map((part) => (part && typeof (part as { text?: unknown }).text === "string"
? ((part as { text: string }).text)
: ""))
.filter(Boolean);
}
return [];
@@ -65,29 +60,6 @@ export function shouldDefaultAllowClassifier(
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
}
/**
* Detect which synthetic-response shape the classifier request expects.
*
* Newer Claude Code builds send a "severity classifier" variant of the same internal
* request: it carries `stop_sequences: [..., "</severity>", ...]` and parses a
* `<severity>N</severity>` reply instead of `<block>no</block>`/`<block>yes</block>`.
* Feeding it the legacy `<block>no</block>` shape is unparseable, so it retries both
* stages and then fails closed — the same "blocking it for safety" failure this compat
* shim exists to avoid. Only `stop_sequences` distinguishes the two shapes; callers
* should only consult this after `shouldDefaultAllowClassifier` has already confirmed
* the request is the classifier (via the system-prompt marker), so an unrelated app
* that merely happens to use `</severity>` as a stop token is never affected (#8189).
*/
export function detectClassifierFormat(
body: Record<string, unknown> | null | undefined
): ClaudeClassifierFormat {
const stopSequences = body?.stop_sequences;
if (Array.isArray(stopSequences) && stopSequences.includes("</severity>")) {
return "severity";
}
return "block";
}
/**
* Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON
* body (matching the upstream reference implementation) — Claude Code's classifier
@@ -95,10 +67,7 @@ export function detectClassifierFormat(
* satisfies both streaming and non-streaming callers without needing to plumb a
* synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers.
*/
export function buildDefaultAllowClaudeMessage(
model?: string | null,
format: ClaudeClassifierFormat = "block"
): {
export function buildDefaultAllowClaudeMessage(model?: string | null): {
success: true;
response: Response;
} {
@@ -107,12 +76,7 @@ export function buildDefaultAllowClaudeMessage(
type: "message",
role: "assistant",
model: model || "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
},
],
content: [{ type: "text", text: "<block>no</block>" }],
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },

View File

@@ -25,8 +25,9 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { updateSettings } = await import("../../src/lib/db/settings.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { shouldDefaultAllowClassifier, detectClassifierFormat, buildDefaultAllowClaudeMessage } =
await import("../../open-sse/handlers/chatCore/claudeClassifierCompat.ts");
const { shouldDefaultAllowClassifier, buildDefaultAllowClaudeMessage } = await import(
"../../open-sse/handlers/chatCore/claudeClassifierCompat.ts"
);
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const originalFetch = globalThis.fetch;
@@ -57,14 +58,6 @@ const CLASSIFIER_BODY = {
max_tokens: 8,
};
// Newer Claude Code builds send a "severity classifier" variant of the same internal
// request: same security-monitor marker, but `stop_sequences` carries `</severity>`
// instead of `</block>`, and it expects a `<severity>N</severity>` reply (#11289).
const SEVERITY_CLASSIFIER_BODY = {
...CLASSIFIER_BODY,
stop_sequences: ["</severity>"],
};
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
@@ -130,12 +123,7 @@ test("detector: always does NOT fire for normal chat without classifier marker (
test("detector: always fires when classifier marker is present", () => {
const classifier = {
system: [
{
type: "text",
text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action.",
},
],
system: [{ type: "text", text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action." }],
stop_sequences: ["</block>"],
};
assert.equal(
@@ -145,21 +133,6 @@ test("detector: always fires when classifier marker is present", () => {
);
});
// ─── Pure detector: detectClassifierFormat (#11289) ──────────────────────────
test("format detector: defaults to 'block' for the legacy </block> classifier shape", () => {
assert.equal(detectClassifierFormat(CLASSIFIER_BODY), "block");
});
test("format detector: returns 'severity' when stop_sequences carries </severity>", () => {
assert.equal(detectClassifierFormat(SEVERITY_CLASSIFIER_BODY), "severity");
});
test("format detector: defaults to 'block' when stop_sequences is missing/empty", () => {
assert.equal(detectClassifierFormat({}), "block");
assert.equal(detectClassifierFormat({ stop_sequences: [] }), "block");
});
// ─── Pure builder: buildDefaultAllowClaudeMessage ────────────────────────────
test("builder: synthetic message text STARTS WITH <block>no</block>", async () => {
@@ -182,16 +155,6 @@ test("builder: synthetic message text STARTS WITH <block>no</block>", async () =
assert.ok(!text.includes("<block>yes"), "must not signal BLOCK");
});
test("builder: format='severity' returns <severity>0</severity> (#11289)", async () => {
const built = buildDefaultAllowClaudeMessage("claude-3-5-haiku-20241022", "severity");
assert.equal(built.success, true);
const payload = (await built.response.json()) as {
content: Array<{ type: string; text?: string }>;
};
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(text, "<severity>0</severity>");
});
// ─── Handler-level: end-to-end short-circuit through handleChatCore ──────────
test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstream, text starts with <block>no</block>", async () => {
@@ -233,44 +196,3 @@ test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstre
globalThis.fetch = originalFetch;
}
});
test("handler: claudeClassifierCompat=auto emits <severity>0</severity> for the severity-classifier shape (#11289)", async () => {
await updateSettings({ claudeClassifierCompat: "auto" });
let fetchCalls = 0;
globalThis.fetch = (async () => {
fetchCalls++;
throw new Error("upstream fetch should NOT be called when the classifier short-circuits");
}) as typeof fetch;
try {
const result = await handleChatCore({
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
credentials: { apiKey: "sk-test", providerSpecificData: {} },
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/messages",
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
headers: new Headers({ accept: "application/json" }),
},
userAgent: "unit-test",
});
assert.equal(fetchCalls, 0, "upstream fetch must NOT be called");
assert.equal(result.success, true, "handleChatCore must report success");
const payload = (await (result as { response: Response }).response.json()) as {
type: string;
content: Array<{ type: string; text?: string }>;
};
assert.equal(payload.type, "message");
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(
text,
"<severity>0</severity>",
`expected severity-classifier response to be <severity>0</severity>, got: ${text}`
);
} finally {
globalThis.fetch = originalFetch;
}
});