From 0e6e37f4f3e587d65fb56bdae935420a7ecc6950 Mon Sep 17 00:00:00 2001 From: Anh Tran Date: Fri, 7 Aug 2026 18:32:46 +0100 Subject: [PATCH] feat(oauth): add Openference OAuth and API key provider integration Wire Openference as a first-party OAuth gateway (PKCE, rotating refresh) and an API-key catalog entry on api.openference.com, with live model discovery, connection testing, free-tier badges, and regression tests. --- open-sse/config/constants.ts | 15 +- open-sse/config/providers/index.ts | 4 + .../registry/openference-api/index.ts | 18 ++ .../providers/registry/openference/index.ts | 25 +++ open-sse/services/tokenRefresh.ts | 14 +- .../tokenRefresh/providers/openference.ts | 92 ++++++++ public/providers/openference.svg | 5 + .../api/oauth/[provider]/[action]/route.ts | 2 +- .../models/discovery/providerModelsConfig.ts | 16 ++ .../[id]/models/discovery/providerSets.ts | 4 + .../providers/[id]/test/oauthTestConfig.ts | 18 ++ src/lib/oauth/constants/oauth.ts | 14 ++ src/lib/oauth/providers/index.ts | 2 + src/lib/oauth/providers/openference.ts | 125 +++++++++++ src/lib/tokenHealthCheck.ts | 1 + src/shared/components/ProviderIcon.tsx | 1 + .../providers/apikey/inference-hosts.ts | 13 ++ src/shared/constants/providers/oauth.ts | 13 ++ tests/unit/oauth-providers-config.test.ts | 5 + ...rence-apikey-provider-registration.test.ts | 89 ++++++++ tests/unit/openference-oauth-provider.test.ts | 199 ++++++++++++++++++ 21 files changed, 664 insertions(+), 11 deletions(-) create mode 100644 open-sse/config/providers/registry/openference-api/index.ts create mode 100644 open-sse/config/providers/registry/openference/index.ts create mode 100644 open-sse/services/tokenRefresh/providers/openference.ts create mode 100644 public/providers/openference.svg create mode 100644 src/lib/oauth/providers/openference.ts create mode 100644 tests/unit/openference-apikey-provider-registration.test.ts create mode 100644 tests/unit/openference-oauth-provider.test.ts diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 81e3c29ee1..ad648f39bf 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -65,27 +65,27 @@ export const PROVIDERS: Record = new Proxy( {} as Record, { get(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Reflect.get(initProviders(), prop, _providers); }, has(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.has(initProviders(), prop); }, ownKeys() { return Reflect.ownKeys(initProviders()); }, getOwnPropertyDescriptor(_, prop) { - if (typeof prop === 'symbol') return undefined; + if (typeof prop === "symbol") return undefined; return Object.getOwnPropertyDescriptor(initProviders(), prop); }, set(_, prop, value) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; (initProviders() as Record)[prop] = value; return true; }, deleteProperty(_, prop) { - if (typeof prop === 'symbol') return false; + if (typeof prop === "symbol") return false; return Reflect.deleteProperty(initProviders(), prop); }, } @@ -124,6 +124,11 @@ export const OAUTH_ENDPOINTS = { auth: "https://github.com/login/oauth/authorize", deviceCode: "https://github.com/login/device/code", }, + openference: { + token: "https://openference.com/oauth/token", + auth: "https://openference.com/app/oauth/authorize", + clientId: "omniroute", + }, }; // Cache TTLs (seconds) diff --git a/open-sse/config/providers/index.ts b/open-sse/config/providers/index.ts index 47a5896784..a64d9b3c04 100644 --- a/open-sse/config/providers/index.ts +++ b/open-sse/config/providers/index.ts @@ -121,6 +121,8 @@ import { chatgpt_webProvider } from "./registry/chatgpt-web/index.ts"; import { openrouterProvider } from "./registry/openrouter/index.ts"; import { cheaperinferenceProvider } from "./registry/cheaperinference/index.ts"; import { openvectaProvider } from "./registry/openvecta/index.ts"; +import { openferenceProvider } from "./registry/openference/index.ts"; +import { openference_apiProvider } from "./registry/openference-api/index.ts"; import { orcarouterProvider } from "./registry/orcarouter/index.ts"; import { copilot_webProvider } from "./registry/copilot-web/index.ts"; import { copilot_m365_webProvider } from "./registry/copilot-m365-web/index.ts"; @@ -345,6 +347,8 @@ export const REGISTRY: Record = { openrouter: openrouterProvider, cheaperinference: cheaperinferenceProvider, openvecta: openvectaProvider, + openference: openferenceProvider, + "openference-api": openference_apiProvider, orcarouter: orcarouterProvider, "copilot-web": copilot_webProvider, "copilot-m365-web": copilot_m365_webProvider, diff --git a/open-sse/config/providers/registry/openference-api/index.ts b/open-sse/config/providers/registry/openference-api/index.ts new file mode 100644 index 0000000000..34a20de303 --- /dev/null +++ b/open-sse/config/providers/registry/openference-api/index.ts @@ -0,0 +1,18 @@ +import type { RegistryEntry } from "../../shared.ts"; +import { buildOpenAiCompatibleRegistryEntry } from "../../shared.ts"; + +/** + * Openference API key — OpenAI-compatible gateway (https://openference.com/). + * + * Bearer API keys (`sk-…`) hit the same api.openference.com/v1/* surface as OAuth + * JWTs. Live model discovery uses NAMED_OPENAI_STYLE_PROVIDERS; the seed below is + * the offline fallback when the live fetch fails. + */ +export const openference_apiProvider: RegistryEntry = buildOpenAiCompatibleRegistryEntry({ + id: "openference-api", + alias: "ofa", + baseUrl: "https://api.openference.com/v1/chat/completions", + responsesBaseUrl: "https://api.openference.com/v1/responses", + passthroughModels: true, + models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }], +}); diff --git a/open-sse/config/providers/registry/openference/index.ts b/open-sse/config/providers/registry/openference/index.ts new file mode 100644 index 0000000000..87af280dcb --- /dev/null +++ b/open-sse/config/providers/registry/openference/index.ts @@ -0,0 +1,25 @@ +import type { RegistryEntry } from "../../shared.ts"; + +/** + * Openference — OpenAI-compatible AI inference gateway (https://openference.com/). + * + * OAuth access tokens are ES256 JWTs accepted as Bearer credentials on + * api.openference.com/v1/*. Live model discovery uses NAMED_OPENAI_STYLE_PROVIDERS; + * seed models below are the offline fallback when the live fetch fails. + */ +export const openferenceProvider: RegistryEntry = { + id: "openference", + alias: "of", + format: "openai", + executor: "default", + baseUrl: "https://api.openference.com/v1/chat/completions", + responsesBaseUrl: "https://api.openference.com/v1/responses", + authType: "oauth", + authHeader: "bearer", + passthroughModels: true, + oauth: { + clientIdDefault: "omniroute", + tokenUrl: "https://openference.com/oauth/token", + }, + models: [{ id: "GLM-5.2", name: "GLM 5.2", contextLength: 850000 }], +}; diff --git a/open-sse/services/tokenRefresh.ts b/open-sse/services/tokenRefresh.ts index 43dcba1a6d..aba42bfcd8 100755 --- a/open-sse/services/tokenRefresh.ts +++ b/open-sse/services/tokenRefresh.ts @@ -48,6 +48,7 @@ import { refreshGoogleToken } from "./tokenRefresh/providers/google.ts"; import { ensureAntigravityProjectAssigned } from "./antigravityProjectBootstrap.ts"; import { persistDiscoveredAntigravityProjectId } from "./antigravityProjectPersist.ts"; import { refreshCodexToken } from "./tokenRefresh/providers/codex.ts"; +import { refreshOpenferenceToken } from "./tokenRefresh/providers/openference.ts"; import { refreshKiroToken } from "./tokenRefresh/providers/kiro.ts"; import { refreshQoderToken } from "./tokenRefresh/providers/qoder.ts"; import { refreshGitHubToken } from "./tokenRefresh/providers/github.ts"; @@ -62,6 +63,7 @@ export { refreshClaudeOAuthToken, refreshGoogleToken, refreshCodexToken, + refreshOpenferenceToken, refreshKiroToken, refreshQoderToken, refreshGitHubToken, @@ -339,10 +341,7 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: !(credentials.projectId || credentials.providerSpecificData?.projectId) ) { try { - const discovered = await ensureAntigravityProjectAssigned( - result.accessToken, - fetch - ); + const discovered = await ensureAntigravityProjectAssigned(result.accessToken, fetch); if (discovered) { result.projectId = discovered; result.providerSpecificData = { @@ -362,7 +361,8 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: }); } } catch (discoveryError) { - const msg = discoveryError instanceof Error ? discoveryError.message : String(discoveryError); + const msg = + discoveryError instanceof Error ? discoveryError.message : String(discoveryError); log?.warn?.("TOKEN", `Antigravity projectId discovery failed: ${msg}`); } } @@ -376,6 +376,9 @@ async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: case "codex": return await refreshCodexToken(credentials.refreshToken, log, proxyConfig); + case "openference": + return await refreshOpenferenceToken(credentials.refreshToken, log, proxyConfig); + case "qoder": return await refreshQoderToken(credentials.refreshToken, log, proxyConfig); @@ -439,6 +442,7 @@ export function supportsTokenRefresh(provider) { "agy", "claude", "codex", + "openference", "qoder", "github", "kiro", diff --git a/open-sse/services/tokenRefresh/providers/openference.ts b/open-sse/services/tokenRefresh/providers/openference.ts new file mode 100644 index 0000000000..5e717acc79 --- /dev/null +++ b/open-sse/services/tokenRefresh/providers/openference.ts @@ -0,0 +1,92 @@ +// @ts-nocheck +import { OAUTH_ENDPOINTS } from "../../../config/constants.ts"; +import { runWithProxyContext } from "../../../utils/proxyFetch.ts"; +import { buildFormParams } from "../shared.ts"; + +/** + * Specialized refresh for Openference OAuth tokens. + * Openference uses rotating (one-time-use) oar_* refresh tokens. + */ +export async function refreshOpenferenceToken(refreshToken, log, proxyConfig: unknown = null) { + try { + const response = await runWithProxyContext(proxyConfig, () => + fetch(OAUTH_ENDPOINTS.openference.token, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: buildFormParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: OAUTH_ENDPOINTS.openference.clientId, + }), + }) + ); + + if (!response.ok) { + const errorText = await response.text(); + + let errorCode = null; + try { + const parsed = JSON.parse(errorText); + errorCode = + parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null); + } catch { + // not JSON, ignore + } + + if ( + errorCode === "invalid_grant" || + errorCode === "token_expired" || + errorCode === "invalid_token" + ) { + log?.error?.( + "TOKEN_REFRESH", + "Openference refresh token already used or invalid. Re-authentication required.", + { + status: response.status, + errorCode, + } + ); + return { error: "unrecoverable_refresh_error", code: errorCode }; + } + + if (response.status === 401) { + const code = errorCode || "unauthorized"; + log?.error?.( + "TOKEN_REFRESH", + "Openference OAuth token endpoint returned 401. Re-authentication required.", + { + status: response.status, + errorCode: code, + } + ); + return { error: "unrecoverable_refresh_error", code }; + } + + log?.error?.("TOKEN_REFRESH", "Failed to refresh Openference token", { + status: response.status, + error: errorText, + }); + return null; + } + + const tokens = await response.json(); + + log?.info?.("TOKEN_REFRESH", "Successfully refreshed Openference token", { + hasNewAccessToken: !!tokens.access_token, + hasNewRefreshToken: !!tokens.refresh_token, + expiresIn: tokens.expires_in, + }); + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token || refreshToken, + expiresIn: tokens.expires_in, + }; + } catch (error) { + log?.error?.("TOKEN_REFRESH", `Network error refreshing Openference token: ${error.message}`); + return null; + } +} diff --git a/public/providers/openference.svg b/public/providers/openference.svg new file mode 100644 index 0000000000..525d9ae0a4 --- /dev/null +++ b/public/providers/openference.svg @@ -0,0 +1,5 @@ + + Openference + + + diff --git a/src/app/api/oauth/[provider]/[action]/route.ts b/src/app/api/oauth/[provider]/[action]/route.ts index df82b63071..dcccd3ff98 100755 --- a/src/app/api/oauth/[provider]/[action]/route.ts +++ b/src/app/api/oauth/[provider]/[action]/route.ts @@ -47,7 +47,7 @@ if (!globalThis.__pkceCallbackStates) { } /** Providers that use the PKCE browser callback flow (like Codex). */ -const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth", "grok-cli"]); +const PKCE_CALLBACK_PROVIDERS = new Set(["codex", "xai-oauth", "grok-cli", "openference"]); /** * Providers whose device flow runs in the user's browser (auth.openai.com blocks diff --git a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts index 6d67077914..e94ffecf26 100644 --- a/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts +++ b/src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts @@ -606,6 +606,22 @@ export const PROVIDER_MODELS_CONFIG: Record = authPrefix: "Bearer ", parseResponse: (data) => data.data || data.models || [], }, + openference: { + url: "https://api.openference.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, + "openference-api": { + url: "https://api.openference.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || data.models || [], + }, fireworks: { url: "https://api.fireworks.ai/inference/v1/models", method: "GET", diff --git a/src/app/api/providers/[id]/models/discovery/providerSets.ts b/src/app/api/providers/[id]/models/discovery/providerSets.ts index 2e3bdef7b3..d443cfabc7 100644 --- a/src/app/api/providers/[id]/models/discovery/providerSets.ts +++ b/src/app/api/providers/[id]/models/discovery/providerSets.ts @@ -71,6 +71,10 @@ export const NAMED_OPENAI_STYLE_PROVIDERS = new Set([ // discovered live from https://api.openvecta.com/v1/models; the registry seed // (registry/openvecta) covers the most-used LLMs as the offline fallback. "openvecta", + // Openference (https://openference.com/) — OAuth JWT or API key on the same + // OpenAI-compatible gateway. Live catalog from api.openference.com/v1/models. + "openference", + "openference-api", // Typhoon (SCB 10X, Thailand) and Inception Labs (Mercury diffusion models) are // OpenAI-compatible providers whose /v1/models endpoint exists and is used for // catalog discovery/key validation (verified 2026-07-22). diff --git a/src/app/api/providers/[id]/test/oauthTestConfig.ts b/src/app/api/providers/[id]/test/oauthTestConfig.ts index ac1aaa1680..2a791bc0e7 100644 --- a/src/app/api/providers/[id]/test/oauthTestConfig.ts +++ b/src/app/api/providers/[id]/test/oauthTestConfig.ts @@ -172,4 +172,22 @@ export const OAUTH_TEST_CONFIG = { extraHeaders: { "User-Agent": "OmniRoute", Accept: "application/vnd.github+json" }, refreshable: true, }, + // Openference: first-party OAuth gateway — list models to verify the JWT without + // consuming inference quota. 402 (no active plan) still means auth succeeded. + openference: { + url: "https://api.openference.com/v1/models", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + acceptStatuses: [402], + }, + of: { + url: "https://api.openference.com/v1/models", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + acceptStatuses: [402], + }, }; diff --git a/src/lib/oauth/constants/oauth.ts b/src/lib/oauth/constants/oauth.ts index 02df89e7c9..c89f5fa63e 100644 --- a/src/lib/oauth/constants/oauth.ts +++ b/src/lib/oauth/constants/oauth.ts @@ -152,6 +152,19 @@ export const XAI_OAUTH_CONFIG = { callbackHost: "127.0.0.1", }; +// Openference OAuth Configuration (Authorization Code Flow with PKCE) +export const OPENFERENCE_CONFIG = { + clientId: "omniroute", + authorizeUrl: "https://openference.com/app/oauth/authorize", + tokenUrl: "https://openference.com/oauth/token", + userinfoUrl: "https://openference.com/oauth/userinfo", + scope: "openid profile email model:invoke offline_access", + codeChallengeMethod: "S256", + loopbackPort: 56123, + callbackPath: "/callback", + callbackHost: "127.0.0.1", +}; + // Kimi Coding OAuth Configuration (Device Code Flow) export const KIMI_CODING_CONFIG = { clientId: resolvePublicCred("kimi_id", "KIMI_CODING_OAUTH_CLIENT_ID"), @@ -544,6 +557,7 @@ export const PROVIDERS = { CODEBUDDY_CN: "codebuddy-cn", GROK_CLI: "grok-cli", XAI_OAUTH: "xai-oauth", + OPENFERENCE: "openference", ZED: "zed", ZED_HOSTED: "zed-hosted", }; diff --git a/src/lib/oauth/providers/index.ts b/src/lib/oauth/providers/index.ts index 06e4813042..97f5f013f6 100644 --- a/src/lib/oauth/providers/index.ts +++ b/src/lib/oauth/providers/index.ts @@ -28,6 +28,7 @@ import { cline } from "./cline"; import { windsurf } from "./windsurf"; import { grokCli } from "./grok-cli"; import { xaiOauth } from "./xai-oauth"; +import { openference } from "./openference"; import { codebuddyCn } from "./codebuddy-cn"; import { zed } from "./zed"; import { zedHosted } from "./zed-hosted"; @@ -60,6 +61,7 @@ export const PROVIDERS = { // under this one entry (#7013) — see grok-cli.ts's mapTokens for the dispatch. "grok-cli": grokCli, "xai-oauth": xaiOauth, + openference, "codebuddy-cn": codebuddyCn, // Zed IDE credential bridge — uses keychain import, not standard OAuth zed, diff --git a/src/lib/oauth/providers/openference.ts b/src/lib/oauth/providers/openference.ts new file mode 100644 index 0000000000..15fb8e8ef8 --- /dev/null +++ b/src/lib/oauth/providers/openference.ts @@ -0,0 +1,125 @@ +import { OPENFERENCE_CONFIG } from "../constants/oauth"; + +const BASE64_BLOCK_SIZE = 4; + +/** Extract display metadata from an Openference id_token (OIDC). */ +export function decodeOpenferenceIdTokenIdentity(idToken: unknown): { + email: string | null; + name: string | null; +} { + if (typeof idToken !== "string") return { email: null, name: null }; + const parts = idToken.split("."); + if (parts.length !== 3) return { email: null, name: null }; + + try { + const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); + const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE; + const payload = JSON.parse( + Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8") + ); + return { + email: payload.email || payload.preferred_username || null, + name: payload.name || null, + }; + } catch { + return { email: null, name: null }; + } +} + +function getOpenferenceUserEmail(userInfo: Record): string | null { + const candidates = [userInfo.email, userInfo.preferred_username]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +} + +function getOpenferenceUserName(userInfo: Record): string | null { + const candidates = [userInfo.name, userInfo.email, userInfo.preferred_username]; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate.trim(); + } + } + return null; +} + +export const openference = { + config: OPENFERENCE_CONFIG, + flowType: "authorization_code_pkce" as const, + fixedPort: OPENFERENCE_CONFIG.loopbackPort, + callbackPath: OPENFERENCE_CONFIG.callbackPath, + callbackHost: OPENFERENCE_CONFIG.callbackHost, + + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = new URLSearchParams({ + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Openference token exchange failed: ${error}`); + } + + return response.json(); + }, + + postExchange: async (tokens) => { + const userinfoUrl = OPENFERENCE_CONFIG.userinfoUrl; + const headers = { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + }; + + const userRes = await fetch(userinfoUrl, { headers }); + const userInfo = userRes.ok ? ((await userRes.json()) as Record) : {}; + + return { userInfo }; + }, + + mapTokens: (tokens, extra) => { + const identity = decodeOpenferenceIdTokenIdentity(tokens.id_token); + const userInfo = (extra?.userInfo ?? {}) as Record; + const email = identity.email || getOpenferenceUserEmail(userInfo); + const name = identity.name || getOpenferenceUserName(userInfo) || email; + + return { + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + email, + name, + providerSpecificData: { + scope: tokens.scope || OPENFERENCE_CONFIG.scope, + tokenType: tokens.token_type || "Bearer", + }, + }; + }, +}; diff --git a/src/lib/tokenHealthCheck.ts b/src/lib/tokenHealthCheck.ts index ce54c2e675..5039e26419 100644 --- a/src/lib/tokenHealthCheck.ts +++ b/src/lib/tokenHealthCheck.ts @@ -723,6 +723,7 @@ export async function checkConnection(conn) { "amazon-q", "gitlab-duo", "claude", + "openference", ]); const isRotatingProvider = ROTATING_REFRESH_PROVIDERS.has( String(conn.provider || "").toLowerCase() diff --git a/src/shared/components/ProviderIcon.tsx b/src/shared/components/ProviderIcon.tsx index 02930e53c7..dc09c3eeb3 100644 --- a/src/shared/components/ProviderIcon.tsx +++ b/src/shared/components/ProviderIcon.tsx @@ -171,6 +171,7 @@ const KNOWN_SVGS = new Set([ "openadapter", "openai", "openclaw", + "openference", "opencode", "openrouter", "orcarouter", diff --git a/src/shared/constants/providers/apikey/inference-hosts.ts b/src/shared/constants/providers/apikey/inference-hosts.ts index 6eeebfdd6a..7f14a46061 100644 --- a/src/shared/constants/providers/apikey/inference-hosts.ts +++ b/src/shared/constants/providers/apikey/inference-hosts.ts @@ -32,6 +32,19 @@ export const APIKEY_PROVIDERS_INFERENCE = { freeNote: "Free credits on signup for OpenAI-compatible inference across LLMs, embeddings, and reasoning models", }, + // Openference — OpenAI-compatible AI inference gateway (https://openference.com/). + // API-key auth via Authorization: Bearer sk-… on the same gateway as OAuth JWTs. + "openference-api": { + id: "openference-api", + alias: "ofa", + name: "Openference API", + icon: "openference", + color: "#6366F1", + textIcon: "OF", + website: "https://openference.com", + hasFree: true, + freeNote: "Free plan: 3-day trial with open-source models — no credit card required", + }, fireworks: { id: "fireworks", alias: "fireworks", diff --git a/src/shared/constants/providers/oauth.ts b/src/shared/constants/providers/oauth.ts index a9bd04b950..e0480da290 100644 --- a/src/shared/constants/providers/oauth.ts +++ b/src/shared/constants/providers/oauth.ts @@ -29,6 +29,19 @@ export const OAUTH_PROVIDERS = { authHint: "Sign in with xAI to use api.x.ai models such as Grok 4.5. This is separate from Grok Build JWT sessions, which use cli-chat-proxy.grok.com and grok-build model aliases.", }, + openference: { + id: "openference", + alias: "of", + name: "Openference", + icon: "openference", + color: "#6366F1", + textIcon: "OF", + website: "https://openference.com", + hasFree: true, + freeNote: "Free plan: 3-day trial with open-source models — no credit card required", + authHint: + "Sign in with your Openference account to route requests through api.openference.com. An active plan is required for inference — OAuth may authenticate but return 402 without one.", + }, "grok-cli": { id: "grok-cli", alias: "gc", diff --git a/tests/unit/oauth-providers-config.test.ts b/tests/unit/oauth-providers-config.test.ts index ef1ed581eb..f5fffee13a 100644 --- a/tests/unit/oauth-providers-config.test.ts +++ b/tests/unit/oauth-providers-config.test.ts @@ -44,6 +44,7 @@ const { TRAE_CONFIG, WINDSURF_CONFIG, XAI_OAUTH_CONFIG, + OPENFERENCE_CONFIG, ZED_HOSTED_CONFIG, } = oauthModule; const { getAntigravityLoadCodeAssistMetadata } = antigravityHeadersModule; @@ -72,6 +73,7 @@ const EXPECTED_PROVIDER_KEYS = [ "devin-cli", "grok-cli", "xai-oauth", + "openference", "codebuddy-cn", "zed", "zed-hosted", @@ -106,6 +108,7 @@ const EXPECTED_CONFIG_BY_PROVIDER = { trae: TRAE_CONFIG, "grok-cli": GROK_BUILD_OAUTH_CONFIG, "xai-oauth": XAI_OAUTH_CONFIG, + openference: OPENFERENCE_CONFIG, "codebuddy-cn": CODEBUDDY_CN_CONFIG, zed: ZED_CONFIG, "zed-hosted": ZED_HOSTED_CONFIG, @@ -154,6 +157,8 @@ const REQUIRED_FIELDS_BY_PROVIDER = { // prettier-ignore "xai-oauth": ["authorizeUrl", "tokenUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"], // prettier-ignore + openference: ["authorizeUrl", "tokenUrl", "userinfoUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"], + // prettier-ignore "grok-cli": ["authorizeUrl", "tokenUrl", "scope", "codeChallengeMethod", "clientId", "loopbackPort", "callbackPath", "callbackHost"], // prettier-ignore "zed-hosted": ["webBaseUrl", "cloudBaseUrl", "llmBaseUrl", "userInfoUrl", "llmTokenUrl", "modelsUrl"], diff --git a/tests/unit/openference-apikey-provider-registration.test.ts b/tests/unit/openference-apikey-provider-registration.test.ts new file mode 100644 index 0000000000..4015326cfb --- /dev/null +++ b/tests/unit/openference-apikey-provider-registration.test.ts @@ -0,0 +1,89 @@ +/** + * Coverage for the Openference API key provider (https://openference.com/). + * + * Validates wiring alongside the OAuth `openference` entry: + * 1. APIKEY_PROVIDERS["openference-api"] — catalog entry (id, alias, name, website, hasFree) + * 2. providerRegistry["openference-api"] — format=openai / executor=default / apikey / bearer + * 3. PROVIDER_MODELS_CONFIG — live /v1/models discovery URL + * 4. NAMED_OPENAI_STYLE_PROVIDERS — classified for live-fetch + * 5. Seeded registry catalog — non-empty, unique ids + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { REGISTRY: providerRegistry } = await import("../../open-sse/config/providerRegistry.ts"); +const { NAMED_OPENAI_STYLE_PROVIDERS, isNamedOpenAIStyleProvider } = + await import("../../src/app/api/providers/[id]/models/discovery/providerSets.ts"); +const { PROVIDER_MODELS_CONFIG } = + await import("../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts"); + +const SPEC = { + id: "openference-api", + alias: "ofa", + name: "Openference API", + website: "https://openference.com", + chatUrl: "https://api.openference.com/v1/chat/completions", + modelsUrl: "https://api.openference.com/v1/models", + expectedSeedIds: ["GLM-5.2"], +}; + +test("APIKEY_PROVIDERS.openference-api is registered with the canonical identity", () => { + const entry = APIKEY_PROVIDERS[SPEC.id]; + assert.ok(entry, `APIKEY_PROVIDERS.${SPEC.id} must be defined`); + assert.equal(entry.id, SPEC.id); + assert.equal(entry.alias, SPEC.alias); + assert.equal(entry.name, SPEC.name); + assert.equal(entry.website, SPEC.website); + assert.equal(entry.icon, "openference"); + assert.equal(typeof entry.textIcon, "string"); + assert.equal(entry.hasFree, true); + assert.equal(typeof entry.freeNote, "string"); + assert.match(entry.color, /^#[0-9A-Fa-f]{6}$/); +}); + +test("providerRegistry exposes the OpenAI-compatible chat completions URL", () => { + assert.equal(providerRegistry[SPEC.id].baseUrl, SPEC.chatUrl); +}); + +test("PROVIDER_MODELS_CONFIG exposes the live /v1/models discovery URL", () => { + const cfg = PROVIDER_MODELS_CONFIG[SPEC.id]; + assert.ok(cfg, `PROVIDER_MODELS_CONFIG.${SPEC.id} must be defined`); + assert.equal(cfg.url, SPEC.modelsUrl); + assert.equal(cfg.method, "GET"); + assert.equal(cfg.authHeader, "Authorization"); + assert.equal(cfg.authPrefix, "Bearer "); + assert.equal(typeof cfg.parseResponse, "function"); +}); + +test("providerRegistry.openference-api uses OpenAI format with bearer apikey auth", () => { + const entry = providerRegistry[SPEC.id]; + assert.ok(entry, `providerRegistry.${SPEC.id} must be defined`); + assert.equal(entry.id, SPEC.id); + assert.equal(entry.alias, SPEC.alias); + assert.equal(entry.format, "openai"); + assert.equal(entry.executor, "default"); + assert.equal(entry.authType, "apikey"); + assert.equal(entry.authHeader, "bearer"); + assert.equal(entry.baseUrl, SPEC.chatUrl); + assert.equal(entry.passthroughModels, true); +}); + +test("openference-api is classified as a named OpenAI-style provider (live-fetch path)", () => { + assert.ok( + NAMED_OPENAI_STYLE_PROVIDERS.has(SPEC.id), + "openference-api must be in NAMED_OPENAI_STYLE_PROVIDERS for live /v1/models fetch" + ); + assert.equal(isNamedOpenAIStyleProvider(SPEC.id), true); +}); + +test("openference-api ships a non-empty unique seed catalog", () => { + const models = providerRegistry[SPEC.id].models; + assert.ok(Array.isArray(models), "registry models must be an array"); + assert.ok(models.length >= 1, "seed list must be non-empty for the offline fallback"); + const ids = models.map((m: { id: string }) => m.id); + assert.equal(new Set(ids).size, ids.length, "seed model ids must be unique"); + for (const expected of SPEC.expectedSeedIds) { + assert.ok(ids.includes(expected), `seed list must include ${expected}`); + } +}); diff --git a/tests/unit/openference-oauth-provider.test.ts b/tests/unit/openference-oauth-provider.test.ts new file mode 100644 index 0000000000..707a3f83a8 --- /dev/null +++ b/tests/unit/openference-oauth-provider.test.ts @@ -0,0 +1,199 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { generateAuthData } from "../../src/lib/oauth/providers.ts"; +import { + openference, + decodeOpenferenceIdTokenIdentity, +} from "../../src/lib/oauth/providers/openference.ts"; +import { OPENFERENCE_CONFIG } from "../../src/lib/oauth/constants/oauth.ts"; +import { getExecutor, hasSpecializedExecutor } from "../../open-sse/executors/index.ts"; +import { openferenceProvider } from "../../open-sse/config/providers/registry/openference/index.ts"; +import { refreshOpenferenceToken } from "../../open-sse/services/tokenRefresh/providers/openference.ts"; +import { OAUTH_TEST_CONFIG } from "../../src/app/api/providers/[id]/test/oauthTestConfig.ts"; +import { testOAuthConnection } from "../../src/app/api/providers/[id]/test/route.ts"; +import { supportsTokenRefresh } from "../../open-sse/services/tokenRefresh.ts"; +import { NAMED_OPENAI_STYLE_PROVIDERS } from "../../src/app/api/providers/[id]/models/discovery/providerSets.ts"; +import { OAUTH_PROVIDERS } from "../../src/shared/constants/providers/oauth.ts"; +import PROVIDERS from "../../src/lib/oauth/providers/index.ts"; + +const originalFetch = globalThis.fetch; + +function createJwt(payload: Record) { + const encode = (value: Record) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + return `${encode({ alg: "none" })}.${encode(payload)}.signature`; +} + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("Openference OAuth builds the PKCE authorization request", () => { + const authData = generateAuthData("openference", "http://127.0.0.1:56123/callback"); + const url = new URL(authData.authUrl); + + assert.equal(url.origin, "https://openference.com"); + assert.equal(url.pathname, "/app/oauth/authorize"); + assert.equal(url.searchParams.get("client_id"), OPENFERENCE_CONFIG.clientId); + assert.equal(url.searchParams.get("scope"), OPENFERENCE_CONFIG.scope); + assert.equal(url.searchParams.get("code_challenge_method"), "S256"); + assert.ok(url.searchParams.get("code_challenge")); + assert.equal(authData.fixedPort, 56123); + assert.equal(authData.callbackPath, "/callback"); + assert.equal(authData.callbackHost, "127.0.0.1"); +}); + +test("Openference OAuth exchanges a code with form-urlencoded PKCE fields", async () => { + globalThis.fetch = async (input, init) => { + assert.equal(String(input), OPENFERENCE_CONFIG.tokenUrl); + assert.equal(init?.method, "POST"); + assert.equal(init?.headers?.["Content-Type"], "application/x-www-form-urlencoded"); + const body = init?.body as URLSearchParams; + assert.equal(body.get("grant_type"), "authorization_code"); + assert.equal(body.get("client_id"), OPENFERENCE_CONFIG.clientId); + assert.equal(body.get("code"), "auth-code"); + assert.equal(body.get("redirect_uri"), "http://127.0.0.1:56123/callback"); + assert.equal(body.get("code_verifier"), "verifier"); + return Response.json({ + access_token: "access", + refresh_token: "oar_refresh", + expires_in: 3600, + id_token: createJwt({ email: "user@openference.com", name: "Openference User" }), + }); + }; + + const tokens = await openference.exchangeToken( + OPENFERENCE_CONFIG, + "auth-code", + "http://127.0.0.1:56123/callback", + "verifier" + ); + assert.equal(tokens.access_token, "access"); +}); + +test("Openference OAuth maps refreshable tokens and id_token display metadata", () => { + const idToken = createJwt({ email: "user@openference.com", name: "Openference User" }); + assert.deepEqual(decodeOpenferenceIdTokenIdentity(idToken), { + email: "user@openference.com", + name: "Openference User", + }); + + const mapped = openference.mapTokens({ + access_token: "access", + refresh_token: "oar_refresh", + id_token: idToken, + expires_in: 3600, + scope: OPENFERENCE_CONFIG.scope, + }); + assert.equal(mapped.accessToken, "access"); + assert.equal(mapped.refreshToken, "oar_refresh"); + assert.equal(mapped.email, "user@openference.com"); + assert.equal(mapped.name, "Openference User"); +}); + +test("Openference OAuth postExchange fetches userinfo when id_token lacks email", async () => { + globalThis.fetch = async (input) => { + assert.equal(String(input), OPENFERENCE_CONFIG.userinfoUrl); + return Response.json({ email: "from-userinfo@openference.com", name: "Userinfo Name" }); + }; + + const extra = await openference.postExchange({ access_token: "access" }); + const mapped = openference.mapTokens( + { access_token: "access", refresh_token: "oar_refresh", expires_in: 3600 }, + extra + ); + assert.equal(mapped.email, "from-userinfo@openference.com"); + assert.equal(mapped.name, "Userinfo Name"); +}); + +test("Openference is registered as an OAuth gateway with default executor", () => { + assert.ok(OAUTH_PROVIDERS.openference); + assert.equal(OAUTH_PROVIDERS.openference.alias, "of"); + assert.equal(OAUTH_PROVIDERS.openference.color, "#6366F1"); + assert.equal(OAUTH_PROVIDERS.openference.hasFree, true); + assert.equal(typeof OAUTH_PROVIDERS.openference.freeNote, "string"); + assert.ok(PROVIDERS.openference); + + assert.equal(openferenceProvider.authType, "oauth"); + assert.equal(openferenceProvider.executor, "default"); + assert.equal(openferenceProvider.baseUrl, "https://api.openference.com/v1/chat/completions"); + assert.deepEqual( + openferenceProvider.models?.map((model) => model.id), + ["GLM-5.2"] + ); + assert.equal(hasSpecializedExecutor("openference"), false); + + const headers = getExecutor("openference").buildHeaders({ accessToken: "oauth-access" }, false); + assert.equal(headers.Authorization, "Bearer oauth-access"); +}); + +test("Openference is classified for live OpenAI-style model discovery", () => { + assert.ok(NAMED_OPENAI_STYLE_PROVIDERS.has("openference")); +}); + +test("OAUTH_TEST_CONFIG covers openference and alias of", () => { + assert.ok((OAUTH_TEST_CONFIG as Record).openference); + assert.ok((OAUTH_TEST_CONFIG as Record).of); +}); + +test("Openference Test Connection probes /v1/models instead of reporting unsupported", async () => { + let calledUrl = ""; + globalThis.fetch = async (url) => { + calledUrl = String(url); + return new Response(JSON.stringify({ data: [{ id: "GLM-5.2" }] }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }; + + const result = await testOAuthConnection({ + provider: "openference", + accessToken: "healthy-access-token", + refreshToken: "oar_refresh", + tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(), + }); + + assert.notEqual(result.diagnosis?.type, "unsupported"); + assert.notEqual(result.error, "Provider test not supported"); + assert.equal(result.valid, true); + assert.equal(calledUrl, "https://api.openference.com/v1/models"); +}); + +test("Openference Test Connection treats 402 as authenticated (plan required for inference)", async () => { + globalThis.fetch = async () => + new Response(JSON.stringify({ error: "payment_required" }), { + status: 402, + headers: { "content-type": "application/json" }, + }); + + const result = await testOAuthConnection({ + provider: "openference", + accessToken: "healthy-access-token", + refreshToken: "oar_refresh", + tokenExpiresAt: new Date(Date.now() + 3600_000).toISOString(), + }); + + assert.equal(result.valid, true); +}); + +test("Openference refresh rotates oar_* tokens", async () => { + assert.equal(supportsTokenRefresh("openference"), true); + + globalThis.fetch = async (input, init) => { + assert.equal(String(input), OPENFERENCE_CONFIG.tokenUrl); + const body = init?.body as URLSearchParams; + assert.equal(body.get("grant_type"), "refresh_token"); + assert.equal(body.get("client_id"), OPENFERENCE_CONFIG.clientId); + assert.equal(body.get("refresh_token"), "oar_old"); + return Response.json({ + access_token: "new-access", + refresh_token: "oar_new", + expires_in: 3600, + }); + }; + + const refreshed = await refreshOpenferenceToken("oar_old", null, null); + assert.equal(refreshed?.accessToken, "new-access"); + assert.equal(refreshed?.refreshToken, "oar_new"); +});