mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-10 00:11:19 +00:00
fix(openai): clarify rejected Realtime Talk API keys [AI] (#102461)
* fix(openai): clarify realtime API key failures * chore: leave release notes to release workflow
This commit is contained in:
parent
20f355f236
commit
fb3f0a2889
3 changed files with 86 additions and 13 deletions
|
|
@ -78,6 +78,7 @@ type OpenAIRealtimeSecretRequest = {
|
|||
url: string;
|
||||
body: unknown;
|
||||
errorMessage: string;
|
||||
authRejectedMessage?: string;
|
||||
missingValueMessage: string;
|
||||
};
|
||||
|
||||
|
|
@ -117,7 +118,14 @@ async function createOpenAIRealtimeSecret(
|
|||
const payload = await (async () => {
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw await createProviderHttpError(response, params.errorMessage);
|
||||
const error = await createProviderHttpError(response, params.errorMessage);
|
||||
// Provider details can echo a masked credential while hiding which
|
||||
// OpenClaw auth source won. Keep the status metadata, but give callers
|
||||
// a bounded remediation for an explicitly configured key.
|
||||
if (response.status === 401 && params.authRejectedMessage) {
|
||||
error.message = params.authRejectedMessage;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return await readProviderJsonResponse<unknown>(response, "openai.realtime-session");
|
||||
} finally {
|
||||
|
|
@ -147,6 +155,7 @@ export async function createOpenAIRealtimeClientSecret(params: {
|
|||
authToken: string;
|
||||
auditContext: string;
|
||||
session: Record<string, unknown>;
|
||||
authRejectedMessage?: string;
|
||||
}): Promise<OpenAIRealtimeClientSecretResult> {
|
||||
const url = `${OPENAI_REALTIME_API_BASE_URL}/realtime/client_secrets`;
|
||||
return createOpenAIRealtimeSecret({
|
||||
|
|
|
|||
|
|
@ -568,6 +568,60 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
|
|||
expect((session as { offerHeaders?: Record<string, string> }).offerHeaders).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(["configured", "profile", "environment"] as const)(
|
||||
"explains how auth precedence affects a rejected %s API key",
|
||||
async (source) => {
|
||||
if (source === "profile") {
|
||||
resolveProviderAuthProfileApiKeyMock.mockResolvedValueOnce("sk-profile"); // pragma: allowlist secret
|
||||
} else if (source === "environment") {
|
||||
vi.stubEnv("OPENAI_API_KEY", "sk-env"); // pragma: allowlist secret
|
||||
}
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: createJsonResponse(
|
||||
{ error: { message: "Incorrect API key provided: sk-proj-***" } },
|
||||
{ status: 401 },
|
||||
),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const provider = buildOpenAIRealtimeVoiceProvider();
|
||||
if (!provider.createBrowserSession) {
|
||||
throw new Error("expected OpenAI realtime provider to support browser sessions");
|
||||
}
|
||||
|
||||
await expect(
|
||||
provider.createBrowserSession({
|
||||
providerConfig:
|
||||
source === "configured"
|
||||
? { apiKey: "sk-stale" } // pragma: allowlist secret
|
||||
: {},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source; Codex OAuth is used only when no API-key source is configured",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves the provider detail when Codex OAuth is rejected", async () => {
|
||||
resolveProviderAuthProfileApiKeyMock
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce("oauth-token"); // pragma: allowlist secret
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: createJsonResponse(
|
||||
{ error: { message: "OAuth token expired" } },
|
||||
{ status: 401 },
|
||||
),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
const provider = buildOpenAIRealtimeVoiceProvider();
|
||||
if (!provider.createBrowserSession) {
|
||||
throw new Error("expected OpenAI realtime provider to support browser sessions");
|
||||
}
|
||||
|
||||
await expect(provider.createBrowserSession({ providerConfig: {} })).rejects.toThrow(
|
||||
"OAuth token expired",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits unsupported OpenAI tool names from browser sessions", async () => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: createJsonResponse({ client_secret: { value: "client-secret-123" } }),
|
||||
|
|
|
|||
|
|
@ -247,10 +247,15 @@ function asUnitInterval(value: unknown): number | undefined {
|
|||
type OpenAIRealtimeApiKeyResolution =
|
||||
| { status: "available"; value: string }
|
||||
| { status: "missing" };
|
||||
type OpenAIRealtimePlatformAuthResolution =
|
||||
| { status: "available"; value: string; source: "api-key" | "oauth" }
|
||||
| { status: "missing" };
|
||||
|
||||
const OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED =
|
||||
"OpenAI Realtime voice requires an OpenAI API key or Codex OAuth sign-in";
|
||||
const OPENAI_REALTIME_API_KEY_REQUIRED = "OpenAI Realtime voice requires an API key";
|
||||
const OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED =
|
||||
"OpenAI Realtime rejected the selected API key. Update or remove the active OpenAI API-key source; Codex OAuth is used only when no API-key source is configured";
|
||||
const OPENAI_REALTIME_PLATFORM_AUTH_PROFILE_TYPES = ["api_key", "oauth"] as const;
|
||||
const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/;
|
||||
const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5000;
|
||||
|
|
@ -380,13 +385,15 @@ function normalizeOpenAIRealtimeTools(
|
|||
async function resolveOpenAIRealtimePlatformAuth(params: {
|
||||
configuredApiKey: string | undefined;
|
||||
cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined;
|
||||
}): Promise<OpenAIRealtimeApiKeyResolution> {
|
||||
}): Promise<OpenAIRealtimePlatformAuthResolution> {
|
||||
const configured = resolveOpenAIRealtimeSecretInput(params.configuredApiKey);
|
||||
if (
|
||||
configured.status === "available" ||
|
||||
hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey)
|
||||
) {
|
||||
return configured;
|
||||
return configured.status === "available"
|
||||
? { ...configured, source: "api-key" }
|
||||
: configured;
|
||||
}
|
||||
|
||||
const profileApiKey = await resolveProviderAuthProfileApiKey({
|
||||
|
|
@ -395,7 +402,7 @@ async function resolveOpenAIRealtimePlatformAuth(params: {
|
|||
profileTypes: ["api_key"],
|
||||
});
|
||||
if (profileApiKey) {
|
||||
return { status: "available", value: profileApiKey };
|
||||
return { status: "available", value: profileApiKey, source: "api-key" };
|
||||
}
|
||||
const hasConfiguredApiKeyProfile = isProviderAuthProfileConfigured({
|
||||
provider: "openai",
|
||||
|
|
@ -405,7 +412,7 @@ async function resolveOpenAIRealtimePlatformAuth(params: {
|
|||
|
||||
const envApiKey = resolveOpenAIRealtimeEnvApiKey();
|
||||
if (envApiKey.status === "available") {
|
||||
return envApiKey;
|
||||
return { ...envApiKey, source: "api-key" };
|
||||
}
|
||||
if (hasConfiguredApiKeyProfile || hasOpenAIRealtimeApiKeyInput(undefined)) {
|
||||
return { status: "missing" };
|
||||
|
|
@ -418,19 +425,19 @@ async function resolveOpenAIRealtimePlatformAuth(params: {
|
|||
includeExternalCliAuth: true,
|
||||
});
|
||||
if (oauthToken) {
|
||||
return { status: "available", value: oauthToken };
|
||||
return { status: "available", value: oauthToken, source: "oauth" };
|
||||
}
|
||||
|
||||
return { status: "missing" };
|
||||
}
|
||||
|
||||
async function requireOpenAIRealtimePlatformAuthToken(params: {
|
||||
async function requireOpenAIRealtimePlatformAuth(params: {
|
||||
configuredApiKey: string | undefined;
|
||||
cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined;
|
||||
}): Promise<string> {
|
||||
}): Promise<Extract<OpenAIRealtimePlatformAuthResolution, { status: "available" }>> {
|
||||
const resolved = await resolveOpenAIRealtimePlatformAuth(params);
|
||||
if (resolved.status === "available") {
|
||||
return resolved.value;
|
||||
return resolved;
|
||||
}
|
||||
throw new Error(OPENAI_REALTIME_PLATFORM_AUTH_REQUIRED);
|
||||
}
|
||||
|
|
@ -815,11 +822,11 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
|||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}> {
|
||||
const authToken = await requireOpenAIRealtimePlatformAuthToken({
|
||||
const auth = await requireOpenAIRealtimePlatformAuth({
|
||||
configuredApiKey: this.config.apiKey,
|
||||
cfg: this.config.cfg,
|
||||
});
|
||||
return this.resolveApiKeyConnectionParams(authToken, model);
|
||||
return this.resolveApiKeyConnectionParams(auth.value, model);
|
||||
}
|
||||
|
||||
private resolveApiKeyConnectionParams(
|
||||
|
|
@ -1465,7 +1472,7 @@ async function createOpenAIRealtimeBrowserSession(
|
|||
}
|
||||
|
||||
const model = req.model ?? config.model ?? OPENAI_REALTIME_DEFAULT_MODEL;
|
||||
const authToken = await requireOpenAIRealtimePlatformAuthToken({
|
||||
const auth = await requireOpenAIRealtimePlatformAuth({
|
||||
configuredApiKey: config.apiKey,
|
||||
cfg: req.cfg,
|
||||
});
|
||||
|
|
@ -1507,9 +1514,12 @@ async function createOpenAIRealtimeBrowserSession(
|
|||
}
|
||||
|
||||
const clientSecret = await createOpenAIRealtimeClientSecret({
|
||||
authToken,
|
||||
authToken: auth.value,
|
||||
auditContext: "openai-realtime-browser-session",
|
||||
session,
|
||||
...(auth.source === "api-key"
|
||||
? { authRejectedMessage: OPENAI_REALTIME_CONFIGURED_API_KEY_REJECTED }
|
||||
: {}),
|
||||
});
|
||||
const offerHeaders = resolveOpenAIRealtimeBrowserOfferHeaders();
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue