mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-22 07:02:16 +03:00
feat(api): flag a pinned account on /v1/combos steps without leaking the id (#11076)
⭐5 — /v1/combos strip connectionId de propósito; dois passos pinando contas DIFERENTES do mesmo provider viram objetos byte-idênticos e um cliente conclui que não há failover. Adiciona accountPinned (boolean derivado do connectionId, nunca vaza o id) em cada passo model; sempre true/false, nunca em combo-ref. Sem rota/schema/mudança de caller. TDD. Fecha #10968.
This commit is contained in:
@@ -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<string, unknown>): 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;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user