fix(gitlab-duo): direct_access 错误保留 HTTP 状态码以触发凭据轮换

机器人指出两处问题,本提交处理:

1) direct_access 带 body message 的错误丢弃了 response.status,导致
   streaming auth-retry 路径(extractStatusFromAssistantError ->
   extractHttpStatusFromError)无法恢复状态、无法刷新/轮换过期 OAuth 或
   配额受限的 broker 凭据。现在即便有 body message 也内嵌 HTTP <status>,
   与 create 路径既有约定一致。新增 401 Unauthorized 回归测试断言状态可恢复,
   并强化既有 403 配额测试。

2) generate-models 种子注释过度暗示生成期会跑 namespace-scoped 发现。
   实际上 gitlab-duo-agent descriptor 故意不带 catalogDiscovery,被
   isCatalogDescriptor 过滤排除在生成发现循环之外,因此生成期绝不会拉取
   某账号的 aiChatAvailableModels,只播种通用 namespace-free fallback。
   修正注释表述,新增两条针对 descriptor 的回归测试:断言 descriptor 无
   catalogDiscovery(永不参与生成发现),以及 fallback 模型不带
   gitlabDuoWorkflowRootNamespaceId。
This commit is contained in:
jiwangyihao
2026-06-23 15:01:49 +08:00
parent 0e78a24443
commit bf1b692df9
5 changed files with 101 additions and 9 deletions
+3
View File
@@ -65,6 +65,9 @@
- Fixed OpenRouter Anthropic models on the Responses path omitting `cache_control`, so prompt caching engages without forcing Chat Completions. ([#3397](https://github.com/can1357/oh-my-pi/issues/3397))
- Fixed OpenRouter Anthropic Responses follow-up requests replaying prior reasoning items with stale signatures, which caused HTTP 400 `Invalid signature in thinking block` errors after a thinking turn. ([#3399](https://github.com/can1357/oh-my-pi/issues/3399))
- Fixed OpenRouter Anthropic models on the Responses path omitting `cache_control`, so prompt caching engages without forcing Chat Completions. `cacheRetention: "long"` now upgrades the breakpoint to `ttl: "1h"`. ([#3397](https://github.com/can1357/oh-my-pi/issues/3397))
### Fixed
- Fixed GitLab Duo Workflow `direct_access` errors dropping the HTTP status when GitLab returned a JSON error body (e.g. a 401 `{"message":"Unauthorized"}` from an expired OAuth token, or a 429 quota body). The thrown error now embeds `HTTP <status>` alongside the body message so the streaming auth-retry path (`extractStatusFromAssistantError` → `extractHttpStatusFromError`) can recover the status and refresh/rotate the parked broker credential instead of surfacing a hard failure.
## [16.1.16] - 2026-06-23
@@ -1400,10 +1400,17 @@ async function requestGitLabDuoWorkflowDirectAccess(
});
if (!response.ok) {
const message = await readGitLabDuoWorkflowResponseErrorMessage(response);
if (message) {
throw new Error(`GitLab Duo Workflow direct_access failed: ${message}`);
}
throw new Error(`GitLab Duo Workflow direct_access failed with HTTP ${response.status}`);
// Always embed the HTTP status, even when the body carries a message: the
// streaming auth-retry/rotation path (`extractStatusFromAssistantError` ->
// `extractHttpStatusFromError`) refreshes/rotates broker credentials only
// when the assistant error exposes `errorStatus` or the message embeds an
// `HTTP <status>` token. A 401 `{"message":"Unauthorized"}` or a 429 quota
// body would otherwise surface as a hard failure with no recoverable status.
throw new Error(
message
? `GitLab Duo Workflow direct_access failed with HTTP ${response.status}: ${message}`
: `GitLab Duo Workflow direct_access failed with HTTP ${response.status}`,
);
}
const payload = (await response.json()) as GitLabDirectAccessResponse;
const token = extractGitLabWorkflowToken(payload);
@@ -38,6 +38,7 @@ import type {
} from "@oh-my-pi/pi-ai/types";
import { AssistantMessageEventStream } from "@oh-my-pi/pi-ai/utils/event-stream";
import { buildModel } from "@oh-my-pi/pi-catalog/build";
import { extractHttpStatusFromError } from "@oh-my-pi/pi-utils";
import { z } from "zod/v4";
const model: Model<"gitlab-duo-agent"> = buildModel({
@@ -1503,7 +1504,51 @@ describe("GitLab Duo Workflow WebSocket state machine", () => {
expect(result.errorMessage).toContain("GitLab Duo Workflow direct_access failed");
expect(result.errorMessage).toContain("USAGE_QUOTA_EXCEEDED");
expect(result.errorMessage).toContain("Usage quota exceeded");
expect(result.errorMessage).not.toBe("GitLab Duo Workflow direct_access failed with HTTP 403");
// The body message must be preserved AND the HTTP status embedded so the
// streaming auth-retry path can recover it (`extractStatusFromAssistantError`
// -> `extractHttpStatusFromError`) and rotate the parked credential.
expect(result.errorMessage).toContain("HTTP 403");
expect(extractHttpStatusFromError({ message: result.errorMessage })).toBe(403);
});
it("preserves the 401 status for an Unauthorized direct_access body so the credential can rotate", async () => {
const fetchImpl: FetchImpl = async (input: string | URL | Request) => {
const url = String(input);
if (url.includes("/api/graphql")) {
return new Response(
JSON.stringify({
data: {
aiChatAvailableModels: {
defaultModel: { name: "Claude", ref: "claude_sonnet_4_6_vertex" },
selectableModels: [],
pinnedModel: null,
},
},
}),
{ status: 200 },
);
}
if (url.includes("/api/v4/ai/duo_workflows/direct_access")) {
// An expired OAuth token: GitLab returns a terse `Unauthorized` body
// with no status digits. Without embedding the HTTP status, the message
// alone ("...failed: Unauthorized") would surface as a hard failure and
// the broker could never refresh/rotate the credential.
return new Response(JSON.stringify({ message: "Unauthorized" }), { status: 401 });
}
return new Response("{}", { status: 404 });
};
const stream = streamGitLabDuoWorkflow(model, context, {
apiKey: "[REDACTED]",
rootNamespaceId: "gid://gitlab/Group/1",
fetch: fetchImpl,
});
const result = await stream.result();
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain("Unauthorized");
expect(result.errorMessage).toContain("HTTP 401");
expect(extractHttpStatusFromError({ message: result.errorMessage })).toBe(401);
});
it("auto-discovers a namespace project for the inline flow when none is configured", async () => {
+9 -4
View File
@@ -500,10 +500,15 @@ async function generateModels() {
}
// Seed the GitLab Duo Agent fallback model so a fresh install (no credentialed
// dynamic discovery/cache yet) still surfaces the provider's default model in the
// built-in catalog. The provider is dynamicModelsAuthoritative, so when live
// `aiChatAvailableModels` discovery succeeds during generation its entries win the
// id-keyed dedup above (catalogProviderModels precede this seed); the seed only
// lands on a credential-less or failed regen.
// built-in catalog. The descriptor deliberately has NO `catalogDiscovery`, so it is
// excluded from the generator's discovery loop (`isCatalogDescriptor` filter above):
// generation never fetches `aiChatAvailableModels` for it. That is intentional —
// Duo discovery is credential- and namespace-scoped, so running it during generation
// would bundle one private account's pinned/selectable models (and its
// `gitlabDuoWorkflowRootNamespaceId`) as authoritative for every fresh install.
// The generic fallback is the only thing bundled; live namespace-scoped models are
// discovered at runtime per credential/workspace. The `authoritativeCatalogProviders`
// guard therefore always passes for this id, kept only to mirror the Sakana seed shape.
if (!authoritativeCatalogProviders.has("gitlab-duo-agent")) {
allModels.push(buildGitLabDuoWorkflowFallbackModel());
}
@@ -3,12 +3,15 @@ import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
buildGitLabDuoWorkflowFallbackModel,
buildGitLabDuoWorkflowModelSpec,
discoverGitLabDuoWorkflowNamespace,
discoverGitLabDuoWorkflowRuntimeNamespace,
fetchGitLabDuoWorkflowModels,
} from "@oh-my-pi/pi-catalog/discovery/gitlab-duo-workflow";
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
import { isCatalogDescriptor } from "@oh-my-pi/pi-catalog/provider-models/descriptor-types";
import { PROVIDER_DESCRIPTORS } from "@oh-my-pi/pi-catalog/provider-models/descriptors";
import { gitLabDuoWorkflowModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/special";
import type { FetchImpl } from "@oh-my-pi/pi-catalog/types";
@@ -525,6 +528,35 @@ describe("GitLab Duo Workflow discovery", () => {
expect(seed?.reasoning).toBe(false);
});
it("keeps the gitlab-duo-agent descriptor out of catalog generation discovery", () => {
// The descriptor must NOT carry `catalogDiscovery`: that field is the sole gate
// for the generator's discovery loop (`isCatalogDescriptor`). Were it present,
// `generate-models` running on a machine with GitLab credentials would fetch the
// account's namespace-scoped `aiChatAvailableModels` and bundle one private
// namespace's pinned/selectable catalog into models.json as authoritative for
// every fresh install. Only the generic, namespace-free fallback may be bundled;
// live namespace-scoped models are discovered at runtime per credential/workspace.
const descriptor = PROVIDER_DESCRIPTORS.find(entry => entry.providerId === "gitlab-duo-agent");
expect(descriptor).toBeDefined();
expect(descriptor?.catalogDiscovery).toBeUndefined();
expect(descriptor && isCatalogDescriptor(descriptor)).toBe(false);
});
it("seeds a namespace-free fallback model carrying no account-scoped namespace id", () => {
// The bundled seed must never leak the generating machine's root namespace.
const seed = buildGitLabDuoWorkflowFallbackModel();
expect(seed.id).toBe("claude_sonnet_4_6_vertex");
expect(seed.provider).toBe("gitlab-duo-agent");
expect(seed).not.toHaveProperty("gitlabDuoWorkflowRootNamespaceId");
// A credentialed runtime discovery, by contrast, pins the namespace it resolved.
const scoped = buildGitLabDuoWorkflowModelSpec(
{ name: "Sonnet", ref: "claude_sonnet_4_6_vertex" },
undefined,
"root-namespace-123",
);
expect(scoped.gitlabDuoWorkflowRootNamespaceId).toBe("root-namespace-123");
});
it("does not include bearer credentials in namespace discovery errors", async () => {
const { fetch } = createMockFetch({ groups: [{ id: "missing" }], models: { missing: null } });