diff --git a/open-sse/executors/github.ts b/open-sse/executors/github.ts index 5399ae0901..8d0083a495 100644 --- a/open-sse/executors/github.ts +++ b/open-sse/executors/github.ts @@ -7,6 +7,18 @@ export class GithubExecutor extends BaseExecutor { super("github", PROVIDERS.github); } + getCopilotToken(credentials) { + return credentials?.copilotToken || credentials?.providerSpecificData?.copilotToken || null; + } + + getCopilotTokenExpiresAt(credentials) { + return ( + credentials?.copilotTokenExpiresAt || + credentials?.providerSpecificData?.copilotTokenExpiresAt || + null + ); + } + buildUrl(model, stream, urlIndex = 0) { const targetFormat = getModelTargetFormat("gh", model); if (targetFormat === "openai-responses") { @@ -73,7 +85,21 @@ export class GithubExecutor extends BaseExecutor { async execute(input: ExecuteInput) { const result = await super.execute(input); - if (!result || !result.response?.body) return result; + if (!result || !result.response) return result; + + if (!input.stream) { + // wreq-js clone/text semantics consume the original response body. Materialize + // non-streaming responses immediately so downstream code always sees a native + // fetch Response with a readable body. + const status = result.response.status; + const statusText = result.response.statusText; + const headers = new Headers(result.response.headers); + const payload = await result.response.text(); + result.response = new Response(payload, { status, statusText, headers }); + return result; + } + + if (!result.response.body) return result; const isStreaming = input.stream === true; const contentType = (result.response.headers.get("content-type") || "").toLowerCase(); @@ -105,7 +131,7 @@ export class GithubExecutor extends BaseExecutor { } buildHeaders(credentials, stream = true) { - const token = credentials.copilotToken || credentials.accessToken; + const token = this.getCopilotToken(credentials) || credentials.accessToken; return { Authorization: `Bearer ${token}`, "Content-Type": "application/json", @@ -213,11 +239,12 @@ export class GithubExecutor extends BaseExecutor { needsRefresh(credentials) { // Always refresh if no copilotToken - if (!credentials.copilotToken) return true; + if (!this.getCopilotToken(credentials)) return true; - if (credentials.copilotTokenExpiresAt) { + const copilotTokenExpiresAt = this.getCopilotTokenExpiresAt(credentials); + if (copilotTokenExpiresAt) { // Handle both Unix timestamp (seconds) and ISO string - let expiresAtMs = credentials.copilotTokenExpiresAt; + let expiresAtMs = copilotTokenExpiresAt; if (typeof expiresAtMs === "number" && expiresAtMs < 1e12) { expiresAtMs = expiresAtMs * 1000; // Convert seconds to ms } else if (typeof expiresAtMs === "string") { diff --git a/open-sse/services/combo.ts b/open-sse/services/combo.ts index 13e9ffc0b1..94870af89e 100644 --- a/open-sse/services/combo.ts +++ b/open-sse/services/combo.ts @@ -1010,17 +1010,16 @@ export async function handleComboChat({ try { const cloned = result.clone(); try { - const errorBody = await cloned.json(); - errorText = - errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; - retryAfter = errorBody?.retryAfter || null; - } catch { - try { - const text = await result.text(); - if (text) errorText = text.substring(0, 500); - } catch { - /* Body consumed */ + const text = await cloned.text(); + if (text) { + errorText = text.substring(0, 500); + const errorBody = JSON.parse(text); + errorText = + errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; + retryAfter = errorBody?.retryAfter || null; } + } catch { + /* Clone parse failed */ } } catch { /* Clone failed */ @@ -1293,17 +1292,16 @@ async function handleRoundRobinCombo({ try { const cloned = result.clone(); try { - const errorBody = await cloned.json(); - errorText = - errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; - retryAfter = errorBody?.retryAfter || null; - } catch { - try { - const text = await result.text(); - if (text) errorText = text.substring(0, 500); - } catch { - /* Body consumed */ + const text = await cloned.text(); + if (text) { + errorText = text.substring(0, 500); + const errorBody = JSON.parse(text); + errorText = + errorBody?.error?.message || errorBody?.error || errorBody?.message || errorText; + retryAfter = errorBody?.retryAfter || null; } + } catch { + /* Clone parse failed */ } } catch { /* Clone failed */ diff --git a/tests/unit/t27-github-copilot-response-format.test.mjs b/tests/unit/t27-github-copilot-response-format.test.mjs index 83945d9be8..4cc439c24a 100644 --- a/tests/unit/t27-github-copilot-response-format.test.mjs +++ b/tests/unit/t27-github-copilot-response-format.test.mjs @@ -1,6 +1,5 @@ import test from "node:test"; import assert from "node:assert/strict"; - const { GithubExecutor } = await import("../../open-sse/executors/github.ts"); const { BaseExecutor } = await import("../../open-sse/executors/base.ts"); @@ -16,6 +15,16 @@ function streamFromChunks(chunks) { }); } +const originalFetch = globalThis.fetch; + +test.afterEach(async () => { + globalThis.fetch = originalFetch; +}); + +test.after(() => { + globalThis.fetch = originalFetch; +}); + test("T27: Claude + response_format=json_object injects system instruction and strips response_format field", () => { const executor = new GithubExecutor(); const request = { @@ -109,3 +118,103 @@ test("T27: streaming error responses keep their original body readable", async ( BaseExecutor.prototype.execute = originalExecute; } }); + +test("T27: requests use copilotToken from providerSpecificData when available", async () => { + globalThis.fetch = async (_url, init = {}) => { + assert.equal(init.headers.Authorization, "Bearer copilot_test"); + return new Response( + JSON.stringify({ + choices: [ + { index: 0, message: { role: "assistant", content: "OK" }, finish_reason: "stop" }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + }; + + const executor = new GithubExecutor(); + const result = await executor.execute({ + model: "gemini-3.1-pro-preview", + body: { messages: [{ role: "user", content: "Ping" }], stream: false }, + stream: false, + credentials: { + accessToken: "ghu_test", + providerSpecificData: { + copilotToken: "copilot_test", + }, + }, + }); + + assert.equal(result.response.status, 200); + assert.match(await result.response.text(), /OK/); +}); + +test("T27: non-stream execute materializes provider responses before returning", async () => { + const executor = new GithubExecutor(); + const originalExecute = BaseExecutor.prototype.execute; + + class WeirdResponse { + constructor(body, init = {}) { + this._body = body; + this.status = init.status || 200; + this.statusText = init.statusText || "OK"; + this.headers = new Headers(init.headers || {}); + this.bodyUsed = false; + this.body = {}; + } + + async text() { + if (this.bodyUsed) { + throw new TypeError("Response body is already used"); + } + this.bodyUsed = true; + return this._body; + } + } + + BaseExecutor.prototype.execute = async () => ({ + response: new WeirdResponse(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + url: "https://api.githubcopilot.com/chat/completions", + }); + + try { + const result = await executor.execute({ + model: "gemini-3.1-pro-preview", + body: { messages: [{ role: "user", content: "Ping" }], stream: false }, + stream: false, + credentials: { + accessToken: "ghu_test", + providerSpecificData: { + copilotToken: "copilot_test", + }, + }, + }); + + assert.equal(result.response.constructor.name, "Response"); + assert.equal(await result.response.text(), JSON.stringify({ ok: true })); + } finally { + BaseExecutor.prototype.execute = originalExecute; + } +}); + +test("T27: needsRefresh respects providerSpecificData copilot token metadata", () => { + const executor = new GithubExecutor(); + const expiresAt = Math.floor(Date.now() / 1000) + 3600; + + assert.equal( + executor.needsRefresh({ + accessToken: "ghu_test", + providerSpecificData: { + copilotToken: "copilot_test", + copilotTokenExpiresAt: expiresAt, + }, + }), + false + ); +});