mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
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.
This commit is contained in:
@@ -65,27 +65,27 @@ export const PROVIDERS: Record<string, LegacyProvider> = new Proxy(
|
||||
{} as Record<string, LegacyProvider>,
|
||||
{
|
||||
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<string, LegacyProvider>)[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)
|
||||
|
||||
@@ -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<string, RegistryEntry> = {
|
||||
openrouter: openrouterProvider,
|
||||
cheaperinference: cheaperinferenceProvider,
|
||||
openvecta: openvectaProvider,
|
||||
openference: openferenceProvider,
|
||||
"openference-api": openference_apiProvider,
|
||||
orcarouter: orcarouterProvider,
|
||||
"copilot-web": copilot_webProvider,
|
||||
"copilot-m365-web": copilot_m365_webProvider,
|
||||
|
||||
18
open-sse/config/providers/registry/openference-api/index.ts
Normal file
18
open-sse/config/providers/registry/openference-api/index.ts
Normal file
@@ -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 }],
|
||||
});
|
||||
25
open-sse/config/providers/registry/openference/index.ts
Normal file
25
open-sse/config/providers/registry/openference/index.ts
Normal file
@@ -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 }],
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
92
open-sse/services/tokenRefresh/providers/openference.ts
Normal file
92
open-sse/services/tokenRefresh/providers/openference.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
5
public/providers/openference.svg
Normal file
5
public/providers/openference.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="128" height="128">
|
||||
<title>Openference</title>
|
||||
<path fill="#6366f1" fill-rule="evenodd" clip-rule="evenodd" d="M12 5C15 5 18 7 20 13C18 19 15 21 12 21C9 21 6 19 4 13C6 7 9 5 12 5ZM8.4 11.6A1 1 0 0 1 10.4 11.6L10.4 14.4A1 1 0 0 1 8.4 14.4ZM13.6 11.6A1 1 0 0 1 15.6 11.6L15.6 14.4A1 1 0 0 1 13.6 14.4Z"/>
|
||||
<circle cx="12" cy="4.5" r="1.3" fill="#6366f1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 432 B |
@@ -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
|
||||
|
||||
@@ -606,6 +606,22 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
|
||||
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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
125
src/lib/oauth/providers/openference.ts
Normal file
125
src/lib/oauth/providers/openference.ts
Normal file
@@ -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, unknown>): 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, unknown>): 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<string, unknown>) : {};
|
||||
|
||||
return { userInfo };
|
||||
},
|
||||
|
||||
mapTokens: (tokens, extra) => {
|
||||
const identity = decodeOpenferenceIdTokenIdentity(tokens.id_token);
|
||||
const userInfo = (extra?.userInfo ?? {}) as Record<string, unknown>;
|
||||
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",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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()
|
||||
|
||||
@@ -171,6 +171,7 @@ const KNOWN_SVGS = new Set([
|
||||
"openadapter",
|
||||
"openai",
|
||||
"openclaw",
|
||||
"openference",
|
||||
"opencode",
|
||||
"openrouter",
|
||||
"orcarouter",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
89
tests/unit/openference-apikey-provider-registration.test.ts
Normal file
89
tests/unit/openference-apikey-provider-registration.test.ts
Normal file
@@ -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}`);
|
||||
}
|
||||
});
|
||||
199
tests/unit/openference-oauth-provider.test.ts
Normal file
199
tests/unit/openference-oauth-provider.test.ts
Normal file
@@ -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<string, unknown>) {
|
||||
const encode = (value: Record<string, unknown>) =>
|
||||
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<string, unknown>).openference);
|
||||
assert.ok((OAUTH_TEST_CONFIG as Record<string, unknown>).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");
|
||||
});
|
||||
Reference in New Issue
Block a user