diff --git a/src/app/api/v1/combos/projectCombo.ts b/src/app/api/v1/combos/projectCombo.ts index 0fdae64ef8..924022d505 100644 --- a/src/app/api/v1/combos/projectCombo.ts +++ b/src/app/api/v1/combos/projectCombo.ts @@ -5,6 +5,10 @@ * returning combo metadata to API-key callers. Kept in a separate module so * the projection can be unit-tested without spinning up the Next.js route. * + * #10968: the projection also reports `accountPinned` per model step — a boolean + * derived from the stripped `connectionId`, so callers can distinguish a combo + * that fails over between two accounts of one provider from a duplicated step. + * * #3979: client-facing combo catalogs (the `/v1/combos`, VS Code and LobeHub / * OpenCode import surfaces) can opt into advertising the combo's resolved * capabilities (multimodal / reasoning / caching) so importing clients enable @@ -17,6 +21,19 @@ export interface PublicComboStep { model?: string; comboName?: string; providerId?: string; + /** + * #10968: whether this step pins one specific account of its provider. + * + * Two steps that pin different accounts of the same provider project to + * identical `{kind, model, providerId}` objects, so a client cannot tell a + * two-account failover from the same step listed twice. This says which it + * is without exposing the `connectionId` the flag is derived from — not even + * a prefix, per the issue. + * + * Set on every `model` step. Absent on `combo-ref`, which routes through + * another combo and has no account of its own. + */ + accountPinned?: boolean; } /** @@ -66,6 +83,10 @@ export function projectComboStep(step: Record): PublicComboStep if (typeof step.providerId === "string" && step.providerId.length > 0) { out.providerId = step.providerId; } + // Same shape test as providerId above. `cleanupComboConnectionRefs` drops the + // key when the connection is deleted, so a step whose pinned account is gone + // reports false rather than pointing at nothing. + out.accountPinned = typeof step.connectionId === "string" && step.connectionId.length > 0; return out; } return null; diff --git a/tests/unit/v1-combos-projection.test.ts b/tests/unit/v1-combos-projection.test.ts index 360844a38f..ada6b02c1d 100644 --- a/tests/unit/v1-combos-projection.test.ts +++ b/tests/unit/v1-combos-projection.test.ts @@ -2,6 +2,9 @@ * Issue #2300 — Public projection of combo metadata for GET /v1/combos. * Verifies that internal routing details (connectionId, weights) are stripped * before being exposed to API-key callers. + * + * Issue #10968 — and that a step still reports *whether* it pins an account, + * which is derivable from the stripped connectionId without exposing it. */ import test from "node:test"; import assert from "node:assert/strict"; @@ -25,6 +28,7 @@ test("#2300 projectComboStep keeps model + providerId, drops connectionId/weight kind: "model", model: "anthropic/claude-sonnet-4", providerId: "anthropic", + accountPinned: true, }); }); @@ -72,7 +76,7 @@ test("#2300 projectCombo preserves name/strategy/description, projects models", name: "my-combo", strategy: "priority", description: "primary route", - models: [{ kind: "model", model: "openai/gpt-5", providerId: "openai" }], + models: [{ kind: "model", model: "openai/gpt-5", providerId: "openai", accountPinned: true }], }); const serialized = JSON.stringify(out); @@ -112,3 +116,94 @@ test("#2300 projectCombo omits description when empty", () => { const out = projectCombo({ name: "no-desc", strategy: "priority", models: [] }); assert.equal("description" in (out ?? {}), false); }); + +/** + * Issue #10968 — two steps that pin different accounts of the same provider are + * indistinguishable in the projection, so a client reads a two-account failover + * as one duplicated step. `accountPinned` says which it is; the connectionId it + * is derived from must stay stripped. + */ +test("#10968 projectComboStep reports a pinned account without the connectionId", () => { + const out = projectComboStep({ + kind: "model", + model: "jina-ai/some-embed", + providerId: "jina-ai", + connectionId: "conn_secret_xyz", + }); + + assert.deepEqual(out, { + kind: "model", + model: "jina-ai/some-embed", + providerId: "jina-ai", + accountPinned: true, + }); + assert.ok(!JSON.stringify(out).includes("conn_secret_xyz"), "connection id must not leak"); +}); + +test("#10968 projectComboStep reports false for an unpinned model step", () => { + assert.equal( + projectComboStep({ kind: "model", model: "jina-ai/some-embed", providerId: "jina-ai" }) + ?.accountPinned, + false + ); +}); + +test("#10968 an empty or non-string connectionId is not a pin", () => { + // Same shape test providerId gets: present but empty pins nothing, and a + // non-string cannot be a connection id at all. + for (const connectionId of ["", null, 0, false, {}, []]) { + assert.equal( + projectComboStep({ kind: "model", model: "openai/gpt-5", connectionId })?.accountPinned, + false, + `connectionId ${JSON.stringify(connectionId)} must not read as a pin` + ); + } +}); + +test("#10968 a combo-ref carries no accountPinned", () => { + const out = projectComboStep({ + kind: "combo-ref", + comboName: "fallback-combo", + connectionId: "conn_ignored", + }); + + assert.deepEqual(out, { kind: "combo-ref", comboName: "fallback-combo" }); + assert.equal("accountPinned" in (out ?? {}), false); +}); + +test("#10968 malformed steps stay null rather than gaining the flag", () => { + assert.equal(projectComboStep({ kind: "model", connectionId: "conn_X" }), null); + assert.equal(projectComboStep({ kind: "unknown", connectionId: "conn_X" }), null); +}); + +test("#10968 two pinned accounts of one provider are no longer identical", () => { + const out = projectCombo({ + name: "failover", + strategy: "priority", + models: [ + { + kind: "model", + model: "jina-ai/some-embed", + providerId: "jina-ai", + connectionId: "conn_wallet_a", + }, + { + kind: "model", + model: "jina-ai/some-embed", + providerId: "jina-ai", + connectionId: "conn_wallet_b", + }, + { kind: "model", model: "jina-ai/some-embed", providerId: "jina-ai" }, + ], + }); + + assert.deepEqual( + out?.models.map((m) => m.accountPinned), + [true, true, false] + ); + + const serialized = JSON.stringify(out); + for (const id of ["conn_wallet_a", "conn_wallet_b"]) { + assert.ok(!serialized.includes(id), `${id} must not leak`); + } +});