mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-17 12:22:34 +03:00
Compare commits
1 Commits
fix/12196-
...
fix/12413-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcd8a11848 |
@@ -54,6 +54,34 @@ async function openBrowser(url) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors src/lib/oauth/providers.ts::isLoopbackHostname — used here to detect
|
||||
// when the redirect_uri the server resolved (and the authorize URL now
|
||||
// advertises) points at a loopback address the CLI never binds a listener on
|
||||
// (issue #12413). Returns false on an unparseable URI rather than throwing.
|
||||
function isLoopbackHost(uri) {
|
||||
try {
|
||||
return /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(new URL(uri).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function printLoopbackRedirectWarning(providerId, redirectUri) {
|
||||
process.stdout.write(
|
||||
`Note: the authorize URL below advertises ${redirectUri}, but this CLI does not\n` +
|
||||
"listen on that port. Right after you approve, the browser is expected to\n" +
|
||||
"show a connection error (e.g. \"This site can't be reached\" / \n" +
|
||||
"ERR_CONNECTION_REFUSED) — that is normal, not a failure. Copy the full URL\n" +
|
||||
"from the address bar anyway and paste it below.\n"
|
||||
);
|
||||
if (providerId === "antigravity") {
|
||||
process.stdout.write(
|
||||
"Tip: `omniroute login antigravity` captures the code automatically and\n" +
|
||||
"avoids that error page entirely.\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function targetApiOptions(opts = {}) {
|
||||
return {
|
||||
baseUrl: opts.baseUrl,
|
||||
@@ -110,6 +138,10 @@ async function runBrowserFlow(def, opts) {
|
||||
const { codeVerifier, state, redirectUri: returnedRedirectUri } = start;
|
||||
const finalRedirectUri = returnedRedirectUri || redirectUri;
|
||||
|
||||
if (finalRedirectUri && isLoopbackHost(finalRedirectUri)) {
|
||||
printLoopbackRedirectWarning(def.id, finalRedirectUri);
|
||||
}
|
||||
|
||||
process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`);
|
||||
if (opts.browser !== false) await openBrowser(url);
|
||||
process.stdout.write(
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- fix(providers): route opencode-go/gpt-5.6-luna to /responses instead of /chat/completions (#12196)
|
||||
@@ -0,0 +1 @@
|
||||
- fix(oauth): warn before the dead localhost:8080 redirect in antigravity/gemini `oauth start` (#12413)
|
||||
@@ -250,16 +250,6 @@ export const opencode_goProvider: RegistryEntry = {
|
||||
supportedThinkingEfforts: ["none", "low", "high", "max"],
|
||||
targetFormat: "openai-responses",
|
||||
},
|
||||
// #12196: the Go upstream serves this model only on /responses —
|
||||
// /chat/completions 500s for it. github already declares the same model
|
||||
// id with targetFormat:"openai-responses" (see github/index.ts).
|
||||
{
|
||||
id: "gpt-5.6-luna",
|
||||
name: "GPT-5.6 Luna",
|
||||
supportsReasoning: true,
|
||||
targetFormat: "openai-responses",
|
||||
maxOutputTokens: 128000,
|
||||
},
|
||||
// Console Go free GLM-tier model (live-verified 2026-08-23): the upstream
|
||||
// rejects every reasoning_effort outside {low, high, max} whenever tools
|
||||
// are present — "[1210] This model always engages in thinking and cannot
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { resolveOpencodeTargetFormat } from "../../open-sse/executors/opencode.ts";
|
||||
|
||||
// Issue #12196: opencode-go/gpt-5.6-luna is served by the Go upstream ONLY on
|
||||
// /responses — /chat/completions 500s for this model. The github provider
|
||||
// already declares targetFormat:"openai-responses" for the same model id, and
|
||||
// opencode-go already does the same for deepseek-v4-pro/deepseek-v4-flash on
|
||||
// this exact provider — but gpt-5.6-luna itself is missing from the
|
||||
// opencode-go registry, so getModelTargetFormat() falls through to null and
|
||||
// resolveOpencodeTargetFormat() defaults to "openai", which makes
|
||||
// OpencodeExecutor.buildUrl() post to /chat/completions instead of /responses.
|
||||
test("opencode-go/gpt-5.6-luna must resolve to the openai-responses target format", () => {
|
||||
const resolved = resolveOpencodeTargetFormat("opencode-go", "gpt-5.6-luna");
|
||||
assert.equal(
|
||||
resolved,
|
||||
"openai-responses",
|
||||
"opencode-go/gpt-5.6-luna resolved to '" +
|
||||
resolved +
|
||||
"' instead of 'openai-responses' — OpencodeExecutor.buildUrl() will post to " +
|
||||
"/chat/completions, which the Go upstream 500s on for this model (issue #12196)"
|
||||
);
|
||||
});
|
||||
|
||||
// Control: the sibling deepseek-v4-flash entry on the SAME opencode-go
|
||||
// provider already declares targetFormat:"openai-responses" and must keep
|
||||
// working — proves the assertion above isn't failing for an unrelated reason
|
||||
// (e.g. a broken import or alias resolution).
|
||||
test("control: opencode-go/deepseek-v4-flash already resolves to openai-responses", () => {
|
||||
const resolved = resolveOpencodeTargetFormat("opencode-go", "deepseek-v4-flash");
|
||||
assert.equal(resolved, "openai-responses");
|
||||
});
|
||||
161
tests/unit/probe-12413-antigravity-oauth-redirect-hint.test.ts
Normal file
161
tests/unit/probe-12413-antigravity-oauth-redirect-hint.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
// Repro probe for issue #12413: `omniroute oauth start --provider antigravity`
|
||||
// prints the Google authorize URL (which advertises
|
||||
// redirect_uri=http://localhost:8080/callback) and then silently waits for a
|
||||
// pasted callback URL/code — nothing in the CLI output warns the operator
|
||||
// that their browser is about to land on a dead port (ERR_CONNECTION_REFUSED)
|
||||
// and that seeing that error is expected, not a failure.
|
||||
//
|
||||
// This asserts the CLI's browser-flow instructions proactively mention the
|
||||
// expected browser error (e.g. "can't be reached" / "connection refused" /
|
||||
// "ERR_CONNECTION_REFUSED") BEFORE the user is sent to authorize.
|
||||
|
||||
function makeResp(data: unknown, status = 200) {
|
||||
return {
|
||||
ok: status < 400,
|
||||
status,
|
||||
json: () => Promise.resolve(data),
|
||||
text: () => Promise.resolve(JSON.stringify(data)),
|
||||
headers: new Headers(),
|
||||
};
|
||||
}
|
||||
|
||||
async function captureStdout(fn: () => Promise<void>) {
|
||||
const chunks: string[] = [];
|
||||
const orig = process.stdout.write.bind(process.stdout);
|
||||
(process.stdout.write as unknown) = (c: string | Uint8Array) => {
|
||||
chunks.push(typeof c === "string" ? c : Buffer.from(c).toString("utf8"));
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
process.stdout.write = orig;
|
||||
}
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
function makeCmd() {
|
||||
return { optsWithGlobals: () => ({ output: "json", quiet: true }) };
|
||||
}
|
||||
|
||||
// readline's `close` fallback (bin/cli/io.mjs) only fires once per stdin EOF —
|
||||
// reusing the real (already-ended) process.stdin across tests in the same file
|
||||
// means the second readline.createInterface() never sees another `end`/`close`
|
||||
// event and hangs forever. Swap in a fresh, already-ended Readable per test
|
||||
// instead of calling `process.stdin.push(null)` on the shared real stream.
|
||||
async function withEndedStdin<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const origStdin = process.stdin;
|
||||
const fakeStdin = new Readable({ read() {} });
|
||||
fakeStdin.push(null);
|
||||
Object.defineProperty(process, "stdin", { value: fakeStdin, configurable: true });
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
Object.defineProperty(process, "stdin", { value: origStdin, configurable: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("runOAuthStart browser flow warns antigravity users before the dead localhost:8080 redirect (#12413)", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const origExit = process.exit;
|
||||
let exitErr: Error | null = null;
|
||||
|
||||
(globalThis.fetch as unknown) = (url: string) => {
|
||||
if (url.includes("/api/oauth/antigravity/authorize")) {
|
||||
return Promise.resolve(
|
||||
makeResp({
|
||||
authUrl:
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback",
|
||||
codeVerifier: "verifier",
|
||||
state: "state123",
|
||||
redirectUri: "http://localhost:8080/callback",
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
};
|
||||
|
||||
(process.exit as unknown) = (code?: number) => {
|
||||
exitErr = new Error(`exit ${code}`);
|
||||
throw exitErr;
|
||||
};
|
||||
|
||||
let out = "";
|
||||
try {
|
||||
const { runOAuthStart } = await import("../../bin/cli/commands/oauth.mjs");
|
||||
out = await withEndedStdin(() =>
|
||||
captureStdout(async () => {
|
||||
try {
|
||||
await runOAuthStart({ provider: "antigravity", browser: false }, makeCmd());
|
||||
} catch (e) {
|
||||
if (e !== exitErr) throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
process.exit = origExit;
|
||||
}
|
||||
|
||||
assert.ok(out.includes("8080"), "should print the advertised (dead) redirect port");
|
||||
|
||||
const mentionsExpectedBrowserError =
|
||||
/can't be reached|connection refused|err_connection_refused|won't load|expected/i.test(out);
|
||||
assert.ok(
|
||||
mentionsExpectedBrowserError,
|
||||
`expected the CLI to warn about the dead-redirect browser error BEFORE the user hits it, got:\n${out}`
|
||||
);
|
||||
});
|
||||
|
||||
test("runOAuthStart browser flow does NOT warn for a non-loopback redirect (claude-code)", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
const origExit = process.exit;
|
||||
let exitErr: Error | null = null;
|
||||
|
||||
(globalThis.fetch as unknown) = (url: string) => {
|
||||
if (url.includes("/api/oauth/claude/authorize")) {
|
||||
return Promise.resolve(
|
||||
makeResp({
|
||||
authUrl: "https://platform.claude.com/oauth/authorize?redirect_uri=fixed",
|
||||
codeVerifier: "verifier",
|
||||
state: "state123",
|
||||
redirectUri: "https://platform.claude.com/oauth/code/callback",
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
|
||||
};
|
||||
|
||||
(process.exit as unknown) = (code?: number) => {
|
||||
exitErr = new Error(`exit ${code}`);
|
||||
throw exitErr;
|
||||
};
|
||||
|
||||
let out = "";
|
||||
try {
|
||||
const { runOAuthStart } = await import("../../bin/cli/commands/oauth.mjs");
|
||||
out = await withEndedStdin(() =>
|
||||
captureStdout(async () => {
|
||||
try {
|
||||
await runOAuthStart({ provider: "claude-code", browser: false }, makeCmd());
|
||||
} catch (e) {
|
||||
if (e !== exitErr) throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
process.exit = origExit;
|
||||
}
|
||||
|
||||
const mentionsExpectedBrowserError =
|
||||
/can't be reached|connection refused|err_connection_refused|won't load/i.test(out);
|
||||
assert.ok(
|
||||
!mentionsExpectedBrowserError,
|
||||
`did not expect a dead-redirect warning for a non-loopback provider, got:\n${out}`
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user