mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-11 17:32:35 +03:00
Compare commits
1 Commits
fix/12681-
...
fix/12190-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1686024792 |
1
changelog.d/fixes/12190-trae-referer-401.md
Normal file
1
changelog.d/fixes/12190-trae-referer-401.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): refresh Trae's stale Referer/Origin and forward user timezone so imported connections stop failing with 401 (#12190)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): send `x-api-key` instead of `Authorization: Bearer` for OpenCode Zen's `/v1/responses` endpoint (Muse Spark Contributor models), fixing a 401 on OmniRoute's auth header (#12633)
|
||||
@@ -1 +0,0 @@
|
||||
- fix(models): declare the real ~1M contextLength for OpenCode Zen's Muse Spark 1.2 models instead of falling back to the 200000 provider default (#12681)
|
||||
@@ -30,25 +30,17 @@ export const opencodeProvider: RegistryEntry = {
|
||||
// content (see issue #10867). The opencode provider is passthrough, so
|
||||
// declaring them here only sets the wire format / capability flags — the
|
||||
// live upstream model list already advertises both ids.
|
||||
// #12681: real window confirmed against the opencode-go registry's own
|
||||
// muse-spark-1.2-contributor entries (contextLength: 1048576, maxOutputTokens:
|
||||
// 131072) — without an explicit value here resolution fell back to the
|
||||
// provider-wide defaultContextLength (200000), understating the real window.
|
||||
{
|
||||
id: "muse-spark-1.2",
|
||||
name: "Muse Spark 1.2",
|
||||
supportsReasoning: true,
|
||||
targetFormat: "openai-responses",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
},
|
||||
{
|
||||
id: "muse-spark-1.2-contributor-free",
|
||||
name: "Muse Spark 1.2 Contributor Free",
|
||||
supportsReasoning: true,
|
||||
targetFormat: "openai-responses",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
},
|
||||
{ id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportsReasoning: true },
|
||||
// #6998: 2026-07-14 refresh — the upstream free tier rotated its lineup;
|
||||
|
||||
@@ -63,17 +63,11 @@ export const opencode_zenProvider: RegistryEntry = {
|
||||
// targetFormat declaration, so requests routed here still hit
|
||||
// /chat/completions with a mismatched or unanswerable body and the
|
||||
// upstream returns an empty message.
|
||||
// #12681: real window confirmed against the opencode-go registry's own
|
||||
// muse-spark-1.2-contributor entries (contextLength: 1048576, maxOutputTokens:
|
||||
// 131072) — without an explicit value here resolution fell back to the
|
||||
// provider-wide defaultContextLength (200000), understating the real window.
|
||||
{
|
||||
id: "muse-spark-1.2",
|
||||
name: "Muse Spark 1.2",
|
||||
supportsReasoning: true,
|
||||
targetFormat: "openai-responses",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
},
|
||||
// Explicit wire-format overlay of the base opencode provider's muse-spark entry
|
||||
// (targetFormat: openai-responses). Keep in sync with base on catalog syncs.
|
||||
@@ -82,8 +76,6 @@ export const opencode_zenProvider: RegistryEntry = {
|
||||
name: "Muse Spark 1.2 Contributor Free",
|
||||
supportsReasoning: true,
|
||||
targetFormat: "openai-responses",
|
||||
contextLength: 1048576,
|
||||
maxOutputTokens: 131072,
|
||||
},
|
||||
|
||||
// ── DeepSeek ────────────────────────────────────────────────
|
||||
|
||||
@@ -31,13 +31,6 @@ import {
|
||||
import { isOpencodeGeoBlocked, proxyKeyOf } from "./opencodeGeoBlock.ts";
|
||||
import { isNetworkRotationSharedEgressGuardEnabled } from "@/shared/utils/featureFlags";
|
||||
|
||||
/**
|
||||
* The main OpenCode Zen host, shared by the `opencode` and `opencode-zen`
|
||||
* registry entries. Used to scope the `x-api-key` auth override (#12633) away
|
||||
* from `opencode-go`, which serves a different upstream (`.../zen/go/v1`).
|
||||
*/
|
||||
const ZEN_BASE_URL = "https://opencode.ai/zen/v1";
|
||||
|
||||
/**
|
||||
* Per-account proxy configuration, persisted by NoAuthAccountCard under
|
||||
* `providerSpecificData.accountProxies` (keyed by the account id, which the UI
|
||||
@@ -783,20 +776,6 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #12633: OpenCode Zen's `/v1/responses` endpoint (reached when
|
||||
* `_requestFormat === "openai-responses"`, e.g. Muse Spark Contributor
|
||||
* models) requires `x-api-key`, not `Authorization: Bearer` — unlike the
|
||||
* default `/chat/completions` endpoint on the same host, which accepts
|
||||
* Bearer. Scoped by baseUrl (not provider id/alias) so this only applies to
|
||||
* the main Zen host (`opencode` / `opencode-zen`, both `https://opencode.ai/zen/v1`)
|
||||
* and never to opencode-go, which serves Responses-format models from a
|
||||
* different upstream (`https://opencode.ai/zen/go/v1`) that expects Bearer.
|
||||
*/
|
||||
private usesZenApiKeyAuth(): boolean {
|
||||
return this._requestFormat === "openai-responses" && this.config?.baseUrl === ZEN_BASE_URL;
|
||||
}
|
||||
|
||||
buildHeaders(
|
||||
credentials: ProviderCredentials | null,
|
||||
stream = true,
|
||||
@@ -813,7 +792,7 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
: undefined;
|
||||
|
||||
if (key) {
|
||||
if (this._requestFormat === "claude" || this.usesZenApiKeyAuth()) {
|
||||
if (this._requestFormat === "claude") {
|
||||
headers["x-api-key"] = key;
|
||||
} else {
|
||||
headers["Authorization"] = `Bearer ${key}`;
|
||||
|
||||
@@ -26,6 +26,19 @@ type ChatMessage = { role?: string; content?: unknown };
|
||||
|
||||
const STREAM_TIMEOUT_MS = parseInt(process.env.TRAE_STREAM_TIMEOUT_MS || "300000", 10);
|
||||
|
||||
// Trae's web client origin moved from solo.trae.ai to work.trae.ai (the SOLO
|
||||
// coding agent is now served under the TraeWork product surface); the backend
|
||||
// appears to validate Origin/Referer against the JWT session's real origin, so
|
||||
// a stale value here produces a clean 401 even with a fresh token (#12190).
|
||||
// Kept overridable — via env for a fleet-wide bump without a code change, and
|
||||
// per-connection via providerSpecificData.refererOrigin for an account that
|
||||
// still authenticates against the legacy host — rather than a second
|
||||
// hardcoded guess that would go stale the same way.
|
||||
const DEFAULT_TRAE_WEB_ORIGIN = (process.env.TRAE_WEB_ORIGIN || "https://work.trae.ai").replace(
|
||||
/\/$/,
|
||||
""
|
||||
);
|
||||
|
||||
function flattenQuery(messages: ChatMessage[]): string {
|
||||
const parts: string[] = [];
|
||||
for (const m of messages) {
|
||||
@@ -61,13 +74,17 @@ export class TraeExecutor extends BaseExecutor {
|
||||
buildHeaders(credentials): Record<string, string> {
|
||||
const token = (credentials.accessToken as string) || "";
|
||||
const psd = (credentials.providerSpecificData as JsonRecord) || {};
|
||||
const webOrigin = ((psd.refererOrigin as string) || DEFAULT_TRAE_WEB_ORIGIN).replace(/\/$/, "");
|
||||
const timezone = psd.userTimezone as string | undefined;
|
||||
return {
|
||||
Authorization: `Cloud-IDE-JWT ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Trae-Client-Type": "web",
|
||||
"X-Preferenced-Language": (psd.appLanguage as string) || "en",
|
||||
"x-user-region": (psd.userRegion as string) || "US",
|
||||
Referer: "https://solo.trae.ai/",
|
||||
Referer: `${webOrigin}/`,
|
||||
Origin: webOrigin,
|
||||
...(timezone ? { "x-trae-user-timezone": timezone } : {}),
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||
|
||||
@@ -20,6 +20,8 @@ import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
* scope — optional, default "marscode-us"
|
||||
* tenant — optional, default "marscode"
|
||||
* region — optional, default "US-East"
|
||||
* userRegion — optional, default "US" (x-user-region header; real value for non-US accounts)
|
||||
* userTimezone — optional, forwarded as x-trae-user-timezone when present
|
||||
*/
|
||||
async function requireOAuthImportAuth(request: Request) {
|
||||
// GHSA-mg76: importing a provider connection is a state-mutating admin action;
|
||||
@@ -51,7 +53,17 @@ export async function POST(request: Request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { accessToken, webId, bizUserId, userUniqueId, scope, tenant, region } = validation.data;
|
||||
const {
|
||||
accessToken,
|
||||
webId,
|
||||
bizUserId,
|
||||
userUniqueId,
|
||||
scope,
|
||||
tenant,
|
||||
region,
|
||||
userRegion,
|
||||
userTimezone,
|
||||
} = validation.data;
|
||||
|
||||
const connection: any = await createProviderConnection({
|
||||
provider: "trae",
|
||||
@@ -71,7 +83,12 @@ export async function POST(request: Request) {
|
||||
aiRegion: region || "US-East",
|
||||
appLanguage: "en",
|
||||
appVersion: "1.0.0.1229",
|
||||
userRegion: "US",
|
||||
// "US" stays the best-effort default so existing imports that omit
|
||||
// userRegion keep behaving as before; a real account region (e.g.
|
||||
// "SG") must be user-supplied — it is not a universal replacement
|
||||
// default (#12190).
|
||||
userRegion: userRegion || "US",
|
||||
...(userTimezone ? { userTimezone } : {}),
|
||||
userIdentity: "Free",
|
||||
authMethod: "imported",
|
||||
},
|
||||
@@ -125,6 +142,18 @@ export async function GET(request: Request) {
|
||||
{ name: "scope", label: "Scope", description: "default: marscode-us", type: "text" },
|
||||
{ name: "tenant", label: "Tenant", description: "default: marscode", type: "text" },
|
||||
{ name: "region", label: "Region", description: "default: US-East", type: "text" },
|
||||
{
|
||||
name: "userRegion",
|
||||
label: "User Region",
|
||||
description: "x-user-region header, e.g. 'SG'. default: US",
|
||||
type: "text",
|
||||
},
|
||||
{
|
||||
name: "userTimezone",
|
||||
label: "User Timezone",
|
||||
description: "x-trae-user-timezone header, e.g. 'America/Recife'. optional",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ export type ParsedTraeCallback = {
|
||||
clientId: string;
|
||||
refreshExpireAt: number | null;
|
||||
authMethod: "oauth_callback";
|
||||
userRegion?: string;
|
||||
userTimezone?: string;
|
||||
};
|
||||
testStatus: "active";
|
||||
};
|
||||
@@ -65,6 +67,13 @@ export function parseTraeCallbackQuery(q: URLSearchParams): ParsedTraeCallback |
|
||||
|
||||
const userId = (info.UserID as string) || "";
|
||||
const region = (info.Region as string) || "US-East";
|
||||
// Best-effort: the /authorize callback's userInfo payload has not been
|
||||
// observed to carry a distinct x-user-region/timezone value distinct from
|
||||
// Region — if Trae ever adds one under these names it propagates
|
||||
// automatically; otherwise buildHeaders() falls back to "US"/no timezone
|
||||
// header exactly as it does today (#12190).
|
||||
const userRegion = (info.UserRegion as string) || undefined;
|
||||
const userTimezone = (info.Timezone as string) || undefined;
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
@@ -90,6 +99,8 @@ export function parseTraeCallbackQuery(q: URLSearchParams): ParsedTraeCallback |
|
||||
clientId: (userJwt.ClientID as string) || "en1oxy7wnw8j9n",
|
||||
refreshExpireAt: refreshExpiresAtMs || null,
|
||||
authMethod: "oauth_callback",
|
||||
...(userRegion ? { userRegion } : {}),
|
||||
...(userTimezone ? { userTimezone } : {}),
|
||||
},
|
||||
testStatus: "active",
|
||||
},
|
||||
|
||||
@@ -42,6 +42,8 @@ type TraeRawTokens = {
|
||||
app_version?: string;
|
||||
userRegion?: string;
|
||||
user_region?: string;
|
||||
userTimezone?: string;
|
||||
user_timezone?: string;
|
||||
userIdentity?: string;
|
||||
user_identity?: string;
|
||||
};
|
||||
@@ -69,6 +71,7 @@ export const trae = {
|
||||
appLanguage: tokens.appLanguage || tokens.app_language || "en",
|
||||
appVersion: tokens.appVersion || tokens.app_version || "1.0.0.1229",
|
||||
userRegion: tokens.userRegion || tokens.user_region || "US",
|
||||
userTimezone: tokens.userTimezone || tokens.user_timezone || undefined,
|
||||
userIdentity: tokens.userIdentity || tokens.user_identity || "Free",
|
||||
// Preserved for callers that key off a machine id (e.g. the IDE flow).
|
||||
machineId: tokens.machineId,
|
||||
|
||||
@@ -183,6 +183,11 @@ export const traeImportSchema = z.object({
|
||||
scope: z.string().trim().optional(),
|
||||
tenant: z.string().trim().optional(),
|
||||
region: z.string().trim().optional(),
|
||||
// Real account region (e.g. "SG") sent as the x-user-region header — the
|
||||
// "US" default only works for US accounts and produces a 401 for others
|
||||
// (#12190). Optional so existing imports keep behaving as before.
|
||||
userRegion: z.string().trim().optional(),
|
||||
userTimezone: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export const kiroImportSchema = z.object({
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
|
||||
|
||||
test("#12633: openai-responses format on opencode-zen sends x-api-key, not Authorization Bearer", () => {
|
||||
const executor = new OpencodeExecutor("opencode-zen");
|
||||
executor._requestFormat = "openai-responses";
|
||||
const headers = executor.buildHeaders(
|
||||
{ apiKey: "sk-zen-test" },
|
||||
true,
|
||||
null,
|
||||
"muse-spark-1.2-contributor-free"
|
||||
);
|
||||
|
||||
assert.equal(headers["x-api-key"], "sk-zen-test");
|
||||
assert.equal(headers["Authorization"], undefined);
|
||||
});
|
||||
|
||||
test("#12633: openai-responses format on the base opencode (oc) provider also sends x-api-key", () => {
|
||||
const executor = new OpencodeExecutor("opencode");
|
||||
executor._requestFormat = "openai-responses";
|
||||
const headers = executor.buildHeaders(
|
||||
{ apiKey: "sk-oc-test" },
|
||||
true,
|
||||
null,
|
||||
"muse-spark-1.2-contributor-free"
|
||||
);
|
||||
|
||||
assert.equal(headers["x-api-key"], "sk-oc-test");
|
||||
assert.equal(headers["Authorization"], undefined);
|
||||
});
|
||||
|
||||
test("#12633: openai-responses format on opencode-go (different upstream endpoint) keeps Authorization Bearer", () => {
|
||||
const executor = new OpencodeExecutor("opencode-go");
|
||||
executor._requestFormat = "openai-responses";
|
||||
const headers = executor.buildHeaders(
|
||||
{ apiKey: "sk-go-test" },
|
||||
true,
|
||||
null,
|
||||
"muse-spark-1.2-contributor"
|
||||
);
|
||||
|
||||
assert.equal(headers["Authorization"], "Bearer sk-go-test");
|
||||
assert.equal(headers["x-api-key"], undefined);
|
||||
});
|
||||
|
||||
test("#12633: claude format keeps sending x-api-key (unchanged behavior)", () => {
|
||||
const executor = new OpencodeExecutor("opencode-zen");
|
||||
executor._requestFormat = "claude";
|
||||
const headers = executor.buildHeaders({ apiKey: "sk-claude-test" }, true, null, "some-model");
|
||||
|
||||
assert.equal(headers["x-api-key"], "sk-claude-test");
|
||||
assert.equal(headers["Authorization"], undefined);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { REGISTRY } from "../../open-sse/config/providerRegistry.ts";
|
||||
import { getTokenLimit } from "../../open-sse/services/contextManager.ts";
|
||||
|
||||
test("#12681: opencode registry declares an explicit real contextLength for muse-spark-1.2 models", () => {
|
||||
const opencode = REGISTRY["opencode"];
|
||||
const museSpark = opencode.models.find((m) => m.id === "muse-spark-1.2");
|
||||
const museSparkFree = opencode.models.find((m) => m.id === "muse-spark-1.2-contributor-free");
|
||||
assert.notEqual(
|
||||
museSpark?.contextLength,
|
||||
undefined,
|
||||
"muse-spark-1.2 should declare its own real contextLength instead of relying on the 200000 provider default"
|
||||
);
|
||||
assert.notEqual(
|
||||
museSparkFree?.contextLength,
|
||||
undefined,
|
||||
"muse-spark-1.2-contributor-free should declare its own real contextLength instead of relying on the 200000 provider default"
|
||||
);
|
||||
});
|
||||
|
||||
test("#12681: opencode-zen registry declares an explicit real contextLength for muse-spark-1.2 models", () => {
|
||||
const zen = REGISTRY["opencode-zen"];
|
||||
const museSpark = zen.models.find((m) => m.id === "muse-spark-1.2");
|
||||
const museSparkFree = zen.models.find((m) => m.id === "muse-spark-1.2-contributor-free");
|
||||
assert.notEqual(museSpark?.contextLength, undefined);
|
||||
assert.notEqual(museSparkFree?.contextLength, undefined);
|
||||
});
|
||||
|
||||
test("#12681: contextManager.getTokenLimit resolves muse-spark-1.2-contributor-free to its real 1M+ window, not the 200000 provider default", () => {
|
||||
assert.equal(getTokenLimit("opencode", "muse-spark-1.2-contributor-free"), 1048576);
|
||||
assert.equal(getTokenLimit("opencode-zen", "muse-spark-1.2-contributor-free"), 1048576);
|
||||
});
|
||||
80
tests/unit/trae-headers-12190.test.ts
Normal file
80
tests/unit/trae-headers-12190.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Import the executor directly (not via executors/index.ts) — index pulls in
|
||||
// the entire provider registry and DB layer which is slow and unnecessary for
|
||||
// the unit-level behavior we want to exercise here.
|
||||
const { TraeExecutor } = await import("../../open-sse/executors/trae.ts");
|
||||
|
||||
const CREDS = {
|
||||
accessToken: "JWT.test.token",
|
||||
providerSpecificData: {
|
||||
webId: "WID",
|
||||
bizUserId: "BUID",
|
||||
userUniqueId: "UUID",
|
||||
scope: "marscode-us",
|
||||
tenant: "marscode",
|
||||
region: "US-East",
|
||||
},
|
||||
};
|
||||
|
||||
test("issue #12190: buildHeaders sends the current work.trae.ai Origin/Referer, not stale solo.trae.ai", () => {
|
||||
const ex = new TraeExecutor();
|
||||
const h = ex.buildHeaders(CREDS);
|
||||
assert.equal(h.Referer, "https://work.trae.ai/", `Referer should be work.trae.ai, got ${h.Referer}`);
|
||||
assert.equal(h.Origin, "https://work.trae.ai", `Origin should be sent, got ${h.Origin}`);
|
||||
});
|
||||
|
||||
test("issue #12190: buildHeaders forwards x-trae-user-timezone from providerSpecificData when present", () => {
|
||||
const ex = new TraeExecutor();
|
||||
const creds = {
|
||||
...CREDS,
|
||||
providerSpecificData: { ...CREDS.providerSpecificData, userTimezone: "America/Recife" },
|
||||
};
|
||||
const h = ex.buildHeaders(creds);
|
||||
assert.equal(
|
||||
h["x-trae-user-timezone"],
|
||||
"America/Recife",
|
||||
`x-trae-user-timezone should be forwarded, got ${h["x-trae-user-timezone"]}`
|
||||
);
|
||||
});
|
||||
|
||||
test("issue #12190: buildHeaders omits x-trae-user-timezone when no timezone is known", () => {
|
||||
const ex = new TraeExecutor();
|
||||
const h = ex.buildHeaders(CREDS);
|
||||
assert.equal(
|
||||
Object.hasOwn(h, "x-trae-user-timezone"),
|
||||
false,
|
||||
"no x-trae-user-timezone key should be sent when providerSpecificData has no userTimezone"
|
||||
);
|
||||
});
|
||||
|
||||
test("issue #12190: buildHeaders still respects a custom providerSpecificData.userRegion", () => {
|
||||
const ex = new TraeExecutor();
|
||||
const creds = {
|
||||
...CREDS,
|
||||
providerSpecificData: { ...CREDS.providerSpecificData, userRegion: "SG" },
|
||||
};
|
||||
const h = ex.buildHeaders(creds);
|
||||
assert.equal(h["x-user-region"], "SG", `x-user-region should respect a custom region, got ${h["x-user-region"]}`);
|
||||
});
|
||||
|
||||
test("issue #12190: buildHeaders defaults x-user-region to US when none is set", () => {
|
||||
const ex = new TraeExecutor();
|
||||
const h = ex.buildHeaders(CREDS);
|
||||
assert.equal(h["x-user-region"], "US");
|
||||
});
|
||||
|
||||
test("issue #12190: buildHeaders lets a per-connection refererOrigin override the default web origin", () => {
|
||||
const ex = new TraeExecutor();
|
||||
const creds = {
|
||||
...CREDS,
|
||||
providerSpecificData: {
|
||||
...CREDS.providerSpecificData,
|
||||
refererOrigin: "https://solo.trae.ai",
|
||||
},
|
||||
};
|
||||
const h = ex.buildHeaders(creds);
|
||||
assert.equal(h.Referer, "https://solo.trae.ai/");
|
||||
assert.equal(h.Origin, "https://solo.trae.ai");
|
||||
});
|
||||
Reference in New Issue
Block a user