@opengeni/api-router 1.3.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-grant-rls.d.ts +7 -0
- package/dist/app.d.ts +2 -2
- package/dist/app.js +1 -1
- package/dist/azure-live.d.ts +28 -0
- package/dist/{chunk-IZEGLLKZ.js → chunk-F3LQ6VWR.js} +1334 -486
- package/dist/{chunk-IZEGLLKZ.js.map → chunk-F3LQ6VWR.js.map} +1 -1
- package/dist/codemode.d.ts +3 -3
- package/dist/http/api-error.d.ts +14 -0
- package/dist/index.js +1 -1
- package/dist/integrations/oauth-profiles.d.ts +3 -1
- package/dist/routes/sessions.d.ts +2 -0
- package/dist/transcription/billing-refusal.d.ts +8 -0
- package/dist/transcription/normalize.d.ts +23 -0
- package/dist/transcription/providers/azure-mai.d.ts +12 -0
- package/dist/transcription/providers/azure-openai.d.ts +4 -0
- package/dist/transcription/providers/openai.d.ts +16 -0
- package/dist/transcription/service.d.ts +10 -1
- package/dist/transcription/wav-duration.d.ts +8 -0
- package/package.json +20 -20
- package/src/access-grant-rls.ts +18 -10
- package/src/app.ts +62 -8
- package/src/azure-live.ts +103 -0
- package/src/codemode.ts +3 -5
- package/src/gateway-realtime.ts +11 -7
- package/src/http/api-error.ts +36 -1
- package/src/integrations/oauth-client.ts +9 -17
- package/src/integrations/oauth-profiles.ts +17 -6
- package/src/routes/editable-artifacts.ts +8 -0
- package/src/routes/session-artifact-associations.ts +80 -6
- package/src/routes/sessions.ts +263 -66
- package/src/routes/transcription-recordings.ts +71 -4
- package/src/routes/transcriptions.ts +26 -2
- package/src/routes/workspaces.ts +49 -7
- package/src/sandbox/channel-a.ts +22 -0
- package/src/transcription/billing-refusal.ts +14 -0
- package/src/transcription/normalize.ts +77 -0
- package/src/transcription/providers/azure-mai.ts +98 -0
- package/src/transcription/providers/azure-openai.ts +16 -7
- package/src/transcription/providers/openai.ts +79 -5
- package/src/transcription/segmenter.ts +7 -1
- package/src/transcription/service.ts +215 -10
- package/src/transcription/wav-duration.ts +39 -0
package/dist/codemode.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type AccessGrant, type AttemptToolCatalog, type CodemodeCallSubmission as CodemodeCallSubmissionValue, type CodemodeOperation } from "@opengeni/contracts";
|
|
2
2
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
3
3
|
import { type SessionTurnForExecution } from "@opengeni/db";
|
|
4
|
-
import { type
|
|
4
|
+
import { type FirstPartyMcpToolPolicySettings } from "@opengeni/config";
|
|
5
5
|
import { type Permission, type Session } from "@opengeni/contracts";
|
|
6
6
|
/** The REST authority a Codemode SDK proxy token may ever carry. */
|
|
7
7
|
export declare const CODEMODE_SESSION_PROXY_PERMISSION_CEILING: readonly ["workspace:read", "sessions:read", "sessions:create", "sessions:control"];
|
|
@@ -13,10 +13,10 @@ export declare const CODEMODE_SESSION_PROXY_PERMISSION_CEILING: readonly ["works
|
|
|
13
13
|
* gets only workspace:read and every proxied /sessions handler refuses it,
|
|
14
14
|
* exactly as its MCP surface would.
|
|
15
15
|
*/
|
|
16
|
-
export declare function codemodeSessionProxyPermissions(settings:
|
|
16
|
+
export declare function codemodeSessionProxyPermissions(settings: FirstPartyMcpToolPolicySettings, session: Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions">): Permission[];
|
|
17
17
|
/** Reuse normal REST handlers, including their resource/command authorization.
|
|
18
18
|
* The exact attempt is checked before issuing this internal-only credential. */
|
|
19
|
-
export declare function codemodeSessionRequest(deps: ApiRouteDeps, grant: AccessGrant, request: Request, path: string, resolveProxySettings?: (session: Session) =>
|
|
19
|
+
export declare function codemodeSessionRequest(deps: ApiRouteDeps, grant: AccessGrant, request: Request, path: string, resolveProxySettings?: (session: Session) => FirstPartyMcpToolPolicySettings): Promise<Request>;
|
|
20
20
|
export type CodemodeGrantAuthority = {
|
|
21
21
|
accountId: string;
|
|
22
22
|
workspaceId: string;
|
package/dist/http/api-error.d.ts
CHANGED
|
@@ -37,6 +37,20 @@ export declare const DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE = "DATABASE_UNAVAILA
|
|
|
37
37
|
* must reconcile by its own idempotency key before resending.
|
|
38
38
|
*/
|
|
39
39
|
export declare function databaseUnavailableHttpError(error: unknown, method: string): ApiHttpError | null;
|
|
40
|
+
/** `details.code` of the retryable 503 for a deadlock/serialization victim. */
|
|
41
|
+
export declare const DATABASE_CONTENTION_ERROR_DETAIL_CODE = "DATABASE_CONTENTION";
|
|
42
|
+
/**
|
|
43
|
+
* PostgreSQL aborted the request's transaction as a deadlock (40P01) or
|
|
44
|
+
* serialization (40001) victim after any in-process retry was exhausted. The
|
|
45
|
+
* aborted transaction committed nothing, so this is transient contention, not
|
|
46
|
+
* an internal error. A read is safe to repeat. For a mutation the default is
|
|
47
|
+
* `outcomeUnknown` because an earlier transaction of the same request may have
|
|
48
|
+
* committed; a route whose only write is the aborted idempotent transaction
|
|
49
|
+
* passes `outcomeUnknown: false`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function databaseContentionHttpError(error: unknown, method: string, options?: {
|
|
52
|
+
outcomeUnknown?: boolean;
|
|
53
|
+
}): ApiHttpError | null;
|
|
40
54
|
/**
|
|
41
55
|
* A typed agent-configuration failure (capability unavailable, conflict,
|
|
42
56
|
* widening, not enabled). Rendered as 422 validation_failed with the specific
|
package/dist/index.js
CHANGED
|
@@ -130,7 +130,9 @@ export type OAuthProviderProfile = {
|
|
|
130
130
|
/** Distinguish a rejected OAuth grant from a temporary verification failure. */
|
|
131
131
|
isRejectedGrant?: (payload: Record<string, unknown>) => boolean;
|
|
132
132
|
validateIdentity: (payload: Record<string, unknown>) => Record<string, string>;
|
|
133
|
-
toolsForScopes: (scopes: readonly string[]
|
|
133
|
+
toolsForScopes: (scopes: readonly string[], deployment: {
|
|
134
|
+
gmailWatchTopicName?: string | undefined;
|
|
135
|
+
}) => Array<{
|
|
134
136
|
name: string;
|
|
135
137
|
description?: string;
|
|
136
138
|
}>;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { ClientSessionEvent, SessionScopeSubjectId, SteerSessionMessageRequest, type SessionStatus, type WorkClaimSubjectFilter, type SessionGoalRevision, type SessionAuthorizationOperation } from "@opengeni/contracts";
|
|
2
2
|
import { decodeSessionListCursor, type SessionDiscoveryCursor } from "@opengeni/db";
|
|
3
3
|
import type { Context, Hono } from "hono";
|
|
4
|
+
import { HTTPException } from "hono/http-exception";
|
|
4
5
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
5
6
|
import { type ViewerServices } from "../sandbox/viewer.js";
|
|
6
7
|
type SessionRouteDeps = ApiRouteDeps & Pick<ViewerServices, "establishSandboxSession">;
|
|
7
8
|
export declare function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void;
|
|
8
9
|
export declare function goalProposalMatchesExpectedRevision(revision: Pick<SessionGoalRevision, "baseObjectiveRevision">, expectedObjectiveRevision: number): boolean;
|
|
10
|
+
export declare function sessionRealtimeHttpError(error: unknown): HTTPException;
|
|
9
11
|
/**
|
|
10
12
|
* Map every mounted session-addressed HTTP path to the host-neutral operation
|
|
11
13
|
* the embedding port authorizes. Returning null is deliberately fail-closed in
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { TranscriptionBillingRefusedError } from "@opengeni/core";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
/**
|
|
4
|
+
* Same body shape as session admission refusals (`code` + `message`, plus
|
|
5
|
+
* allowance `scope`/`resetsAt`), so SDK/React clients classify it as a
|
|
6
|
+
* definitive credit/allowance refusal rather than a retryable failure.
|
|
7
|
+
*/
|
|
8
|
+
export declare function transcriptionBillingRefusal(c: Context, error: TranscriptionBillingRefusedError): Response;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decode one uploaded recording to 16 kHz mono PCM WAV with a hard decode
|
|
3
|
+
* ceiling, and measure its duration from the bytes the server produced.
|
|
4
|
+
*/
|
|
5
|
+
export type TranscriptionAudioNormalizer = (input: {
|
|
6
|
+
audio: Uint8Array;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
/** Longest accepted recording; anything longer is refused, never truncated. */
|
|
9
|
+
maxDurationSeconds: number;
|
|
10
|
+
signal?: AbortSignal | undefined;
|
|
11
|
+
}) => Promise<{
|
|
12
|
+
bytes: Uint8Array;
|
|
13
|
+
durationSeconds: number;
|
|
14
|
+
}>;
|
|
15
|
+
/**
|
|
16
|
+
* ffmpeg decodes at most `maxDurationSeconds + 1` seconds (`-t`), so a long or
|
|
17
|
+
* hostile upload costs bounded CPU and disk. Output that reaches that ceiling
|
|
18
|
+
* was longer than the limit and is refused as `too_large` before any provider
|
|
19
|
+
* call, so the transcript the user receives is never silently cut short.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createFfmpegAudioNormalizer(input: {
|
|
22
|
+
ffmpegPath: string;
|
|
23
|
+
}): TranscriptionAudioNormalizer;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { VoiceInputPricing } from "@opengeni/config";
|
|
2
|
+
import type { TranscriptionProvider } from "@opengeni/core";
|
|
3
|
+
/** Azure Speech file transcription; credentials and model selection stay server-side. */
|
|
4
|
+
export declare function createMaiTranscriptionProvider(input: {
|
|
5
|
+
endpoint: string;
|
|
6
|
+
apiKey: string;
|
|
7
|
+
apiVersion: string;
|
|
8
|
+
model: string;
|
|
9
|
+
pricing?: VoiceInputPricing | null;
|
|
10
|
+
ffmpegPath: string;
|
|
11
|
+
fetch?: typeof fetch;
|
|
12
|
+
}): TranscriptionProvider;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type VoiceInputPricing } from "@opengeni/config";
|
|
1
2
|
import type { TranscriptionProvider } from "@opengeni/core";
|
|
2
3
|
export declare function createAzureOpenAiTranscriptionProvider(input: {
|
|
3
4
|
endpoint: string;
|
|
@@ -5,5 +6,8 @@ export declare function createAzureOpenAiTranscriptionProvider(input: {
|
|
|
5
6
|
apiVersion: string;
|
|
6
7
|
apiKey: string | null;
|
|
7
8
|
adToken: string | null;
|
|
9
|
+
/** Underlying model of the deployment; defaults to the deployment name. */
|
|
10
|
+
model?: string;
|
|
11
|
+
pricing?: VoiceInputPricing | null;
|
|
8
12
|
fetch?: typeof fetch;
|
|
9
13
|
}): TranscriptionProvider;
|
|
@@ -1,9 +1,25 @@
|
|
|
1
|
+
import { type VoiceInputPricing, type VoiceInputUsage } from "@opengeni/config";
|
|
1
2
|
import { type TranscriptionProvider, TranscriptionServiceError } from "@opengeni/core";
|
|
2
3
|
export declare function createOpenAiTranscriptionProvider(input: {
|
|
3
4
|
apiKey: string;
|
|
4
5
|
baseUrl: string;
|
|
5
6
|
model: string;
|
|
7
|
+
pricing?: VoiceInputPricing | null;
|
|
6
8
|
fetch?: typeof fetch;
|
|
7
9
|
}): TranscriptionProvider;
|
|
10
|
+
/**
|
|
11
|
+
* OpenAI-compatible `/audio/transcriptions` JSON (OpenAI and Azure OpenAI).
|
|
12
|
+
* `usage` is `{type:"tokens", input_tokens, input_token_details:{audio_tokens,
|
|
13
|
+
* text_tokens}, output_tokens, total_tokens}` for the gpt-4o-transcribe family
|
|
14
|
+
* or `{type:"duration", seconds}` for duration-billed models; Whisper
|
|
15
|
+
* `verbose_json` carries the processed audio `duration`. Unusable usage is
|
|
16
|
+
* dropped (null), never guessed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseTranscriptionResponseBody(body: unknown): {
|
|
19
|
+
text: string;
|
|
20
|
+
languages: string[];
|
|
21
|
+
usage: VoiceInputUsage | null;
|
|
22
|
+
} | null;
|
|
23
|
+
export declare function parseTranscriptionUsage(body: Record<string, unknown>): VoiceInputUsage | null;
|
|
8
24
|
export declare function responseError(status: number): TranscriptionServiceError;
|
|
9
25
|
export declare function fetchError(error: unknown): TranscriptionServiceError;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Settings } from "@opengeni/config";
|
|
2
|
-
import { type TranscriptionAvailabilityContext, type TranscriptionProvider, type TranscriptionService } from "@opengeni/core";
|
|
2
|
+
import { type TranscriptionAvailabilityContext, type TranscriptionBilling, type TranscriptionProvider, type TranscriptionService } from "@opengeni/core";
|
|
3
3
|
import type { Database } from "@opengeni/db";
|
|
4
|
+
import { type TranscriptionAudioNormalizer } from "./normalize.js";
|
|
4
5
|
export declare function createTranscriptionService(input: {
|
|
5
6
|
settings: Settings;
|
|
6
7
|
db: Database;
|
|
@@ -11,6 +12,14 @@ export declare function createTranscriptionService(input: {
|
|
|
11
12
|
providerRequestTimeoutMilliseconds?: number;
|
|
12
13
|
/** Test seam for evaluating persisted absolute deadlines after delayed setup. */
|
|
13
14
|
now?: () => Date;
|
|
15
|
+
/** Credit admission/settlement for deployment-funded providers. */
|
|
16
|
+
billing?: TranscriptionBilling;
|
|
17
|
+
/** Test seam: trusted one-shot decode + duration (defaults to ffmpeg). */
|
|
18
|
+
normalizeAudio?: TranscriptionAudioNormalizer;
|
|
19
|
+
/** Loud operator log for settlement failures after provider success. */
|
|
20
|
+
log?: (message: string, attributes: Record<string, string | number | boolean>) => void;
|
|
21
|
+
/** In-process settlement retry pacing after an inline failure. */
|
|
22
|
+
settlementRetryDelaysMilliseconds?: readonly number[];
|
|
14
23
|
}): TranscriptionService;
|
|
15
24
|
export declare function remainingTranscriptionProviderRequestMilliseconds(providerDeadlineAt: Date, now: Date): number;
|
|
16
25
|
export declare function orderedProviders(providers: readonly TranscriptionProvider[], context: TranscriptionAvailabilityContext): TranscriptionProvider[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration of a PCM RIFF/WAVE buffer the server itself produced (resumable
|
|
3
|
+
* provider segments are ffmpeg-normalized WAV). Used only as the trusted
|
|
4
|
+
* billing fallback when a provider reports no usage; never applied to
|
|
5
|
+
* client-uploaded bytes. Returns null for anything that is not a well-formed
|
|
6
|
+
* WAV with a positive byte rate.
|
|
7
|
+
*/
|
|
8
|
+
export declare function wavDurationSeconds(bytes: Uint8Array): number | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/api-router",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -43,25 +43,25 @@
|
|
|
43
43
|
"@hono/zod-validator": "^0.7.6",
|
|
44
44
|
"@llamaindex/liteparse": "2.14.2",
|
|
45
45
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
46
|
-
"@opengeni/agent-proto": "1.
|
|
47
|
-
"@opengeni/artifact-tool": "1.
|
|
48
|
-
"@opengeni/capabilities": "1.
|
|
49
|
-
"@opengeni/codemode": "1.
|
|
50
|
-
"@opengeni/codex": "1.
|
|
51
|
-
"@opengeni/config": "1.
|
|
52
|
-
"@opengeni/contracts": "1.
|
|
53
|
-
"@opengeni/core": "1.
|
|
54
|
-
"@opengeni/db": "1.
|
|
55
|
-
"@opengeni/documents": "1.
|
|
56
|
-
"@opengeni/events": "1.
|
|
57
|
-
"@opengeni/github": "1.
|
|
58
|
-
"@opengeni/interaction": "1.
|
|
59
|
-
"@opengeni/network": "1.
|
|
60
|
-
"@opengeni/observability": "1.
|
|
61
|
-
"@opengeni/runtime": "1.
|
|
62
|
-
"@opengeni/storage": "1.
|
|
63
|
-
"@opengeni/tool-gateway": "1.
|
|
64
|
-
"@opengeni/xai-subscription": "1.
|
|
46
|
+
"@opengeni/agent-proto": "1.4.2",
|
|
47
|
+
"@opengeni/artifact-tool": "1.4.2",
|
|
48
|
+
"@opengeni/capabilities": "1.4.2",
|
|
49
|
+
"@opengeni/codemode": "1.4.2",
|
|
50
|
+
"@opengeni/codex": "1.4.2",
|
|
51
|
+
"@opengeni/config": "1.4.2",
|
|
52
|
+
"@opengeni/contracts": "1.4.2",
|
|
53
|
+
"@opengeni/core": "1.4.2",
|
|
54
|
+
"@opengeni/db": "1.4.2",
|
|
55
|
+
"@opengeni/documents": "1.4.2",
|
|
56
|
+
"@opengeni/events": "1.4.2",
|
|
57
|
+
"@opengeni/github": "1.4.2",
|
|
58
|
+
"@opengeni/interaction": "1.4.2",
|
|
59
|
+
"@opengeni/network": "1.4.2",
|
|
60
|
+
"@opengeni/observability": "1.4.2",
|
|
61
|
+
"@opengeni/runtime": "1.4.2",
|
|
62
|
+
"@opengeni/storage": "1.4.2",
|
|
63
|
+
"@opengeni/tool-gateway": "1.4.2",
|
|
64
|
+
"@opengeni/xai-subscription": "1.4.2",
|
|
65
65
|
"@temporalio/client": "^1.17.0",
|
|
66
66
|
"better-auth": "^1.6.14",
|
|
67
67
|
"hono": "^4.12.18",
|
package/src/access-grant-rls.ts
CHANGED
|
@@ -8,25 +8,33 @@ import {
|
|
|
8
8
|
freezeAgentLearningPolicy,
|
|
9
9
|
withSessionRlsActorContext,
|
|
10
10
|
withCreditDebitAttribution,
|
|
11
|
+
type CreditDebitAttribution,
|
|
12
|
+
currentCreditDebitAttribution,
|
|
11
13
|
} from "@opengeni/db";
|
|
12
14
|
import { HTTPException } from "hono/http-exception";
|
|
13
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Trusted payer for a non-agent grant: an authenticated human session pays as
|
|
18
|
+
* that human; keys and services are service work. An agent attempt must be
|
|
19
|
+
* resolved through the validated middleware context installed below.
|
|
20
|
+
*/
|
|
21
|
+
export function creditDebitAttributionForGrant(grant: AccessGrant): CreditDebitAttribution {
|
|
22
|
+
if (grant.principalKind === "agent_attempt") return currentCreditDebitAttribution();
|
|
23
|
+
return grant.principalKind === "human_session"
|
|
24
|
+
? { kind: "human", initiatingHumanSubjectId: grant.subjectId }
|
|
25
|
+
: ["service", "api_key", "configured_key", "mcp_gateway"].includes(grant.principalKind ?? "")
|
|
26
|
+
? { kind: "service" }
|
|
27
|
+
: { kind: "unknown" };
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
export async function withAccessGrantSessionRlsContext<T>(
|
|
15
31
|
deps: Pick<ApiRouteDeps, "db">,
|
|
16
32
|
grant: AccessGrant,
|
|
17
33
|
fn: () => Promise<T>,
|
|
18
34
|
): Promise<T> {
|
|
19
35
|
if (grant.principalKind !== "agent_attempt") {
|
|
20
|
-
return await withCreditDebitAttribution(
|
|
21
|
-
grant.
|
|
22
|
-
? { kind: "human", initiatingHumanSubjectId: grant.subjectId }
|
|
23
|
-
: grant.principalKind === "service" ||
|
|
24
|
-
grant.principalKind === "api_key" ||
|
|
25
|
-
grant.principalKind === "configured_key" ||
|
|
26
|
-
grant.principalKind === "mcp_gateway"
|
|
27
|
-
? { kind: "service" }
|
|
28
|
-
: { kind: "unknown" },
|
|
29
|
-
() => withSessionRlsActorContext({ subjectId: grant.subjectId }, fn),
|
|
36
|
+
return await withCreditDebitAttribution(creditDebitAttributionForGrant(grant), () =>
|
|
37
|
+
withSessionRlsActorContext({ subjectId: grant.subjectId }, fn),
|
|
30
38
|
);
|
|
31
39
|
}
|
|
32
40
|
const callerSessionId = grant.metadata?.sessionId;
|
package/src/app.ts
CHANGED
|
@@ -24,9 +24,10 @@ import {
|
|
|
24
24
|
canonicalPublicOrigin,
|
|
25
25
|
configuredAllowedReasoningEfforts,
|
|
26
26
|
resolveFirstPartyMcpToolPolicy,
|
|
27
|
-
resolveVoiceInputProviderRegistry,
|
|
28
27
|
UnsupportedLatencyModeError,
|
|
28
|
+
voiceInputPricingIssues,
|
|
29
29
|
type Settings,
|
|
30
|
+
type FirstPartyMcpToolPolicySettings,
|
|
30
31
|
} from "@opengeni/config";
|
|
31
32
|
import {
|
|
32
33
|
AGENT_CAPABILITY_IDS,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
resolveWorkspaceMemoryEnabled,
|
|
41
42
|
resolveWorkspaceMemoryPromptMode,
|
|
42
43
|
VOICE_INPUT_ACCEPTED_MIME_TYPES,
|
|
44
|
+
WorkspaceVoiceInputSettings,
|
|
43
45
|
TRANSCRIPTION_RECORDING_PROVIDER_SEGMENT_SECONDS,
|
|
44
46
|
ToolGatewayApprovalRequest,
|
|
45
47
|
ToolGatewayCallRequest,
|
|
@@ -97,7 +99,9 @@ import {
|
|
|
97
99
|
ApiHttpError,
|
|
98
100
|
agentConfigHttpError,
|
|
99
101
|
allowanceExhaustedHttpError,
|
|
102
|
+
DATABASE_CONTENTION_ERROR_DETAIL_CODE,
|
|
100
103
|
DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE,
|
|
104
|
+
databaseContentionHttpError,
|
|
101
105
|
databaseUnavailableHttpError,
|
|
102
106
|
modelUnavailableHttpError,
|
|
103
107
|
scheduledTaskTargetAccessHttpError,
|
|
@@ -491,12 +495,27 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
491
495
|
}, MANAGED_AUTH_REAPER_INTERVAL_MS);
|
|
492
496
|
(timer as ReturnType<typeof setInterval> & { unref?: () => void }).unref?.();
|
|
493
497
|
}
|
|
498
|
+
if (deps.transcription === undefined) {
|
|
499
|
+
// Voice pricing problems never fail boot (workers share this config); the
|
|
500
|
+
// affected provider is withheld and the operator sees exactly why, once.
|
|
501
|
+
for (const issue of voiceInputPricingIssues(deps.settings)) {
|
|
502
|
+
observability.error(
|
|
503
|
+
`Voice input provider ${issue.providerId} is unavailable: ${issue.message}`,
|
|
504
|
+
{
|
|
505
|
+
providerId: issue.providerId,
|
|
506
|
+
env: issue.env,
|
|
507
|
+
reason: issue.reason,
|
|
508
|
+
},
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
494
512
|
const transcription =
|
|
495
513
|
deps.transcription === undefined
|
|
496
514
|
? createTranscriptionService({
|
|
497
515
|
settings: deps.settings,
|
|
498
516
|
db: deps.db,
|
|
499
517
|
...(deps.codexFetch ? { codexFetch: deps.codexFetch } : {}),
|
|
518
|
+
log: (message, attributes) => observability.error(message, attributes),
|
|
500
519
|
})
|
|
501
520
|
: deps.transcription;
|
|
502
521
|
const transcriptionSegmenter =
|
|
@@ -644,8 +663,14 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
644
663
|
"X-OpenGeni-Access-Key",
|
|
645
664
|
"X-OpenGeni-Api-Contract",
|
|
646
665
|
"X-OpenGeni-Actor-Epoch",
|
|
666
|
+
// Resumable voice-input chunk integrity/timing (cross-origin embeds).
|
|
667
|
+
"X-OpenGeni-Chunk-Duration-Milliseconds",
|
|
668
|
+
"X-OpenGeni-Chunk-Sha256",
|
|
669
|
+
"X-OpenGeni-Chunk-Start-Milliseconds",
|
|
647
670
|
"X-OpenGeni-Correlation-Id",
|
|
648
671
|
"X-OpenGeni-Session-Csrf",
|
|
672
|
+
// Session scope for a session proxy; the API itself ignores it.
|
|
673
|
+
"X-OpenGeni-Session-Id",
|
|
649
674
|
"X-OpenGeni-Site-Id",
|
|
650
675
|
"X-OpenGeni-Site-Version",
|
|
651
676
|
"X-OpenGeni-Subject",
|
|
@@ -1191,6 +1216,8 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
1191
1216
|
creditsAvailable: false,
|
|
1192
1217
|
});
|
|
1193
1218
|
let modelSelectionForbidden = false;
|
|
1219
|
+
let voiceInputAvailable = false;
|
|
1220
|
+
let voiceInputProviders: string[] = [];
|
|
1194
1221
|
const requestedWorkspaceId = c.req.query("workspaceId");
|
|
1195
1222
|
if (requestedWorkspaceId !== undefined && requestedWorkspaceId.trim() === "") {
|
|
1196
1223
|
throw new HTTPException(422, { message: "workspaceId must not be empty" });
|
|
@@ -1241,6 +1268,25 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
1241
1268
|
selections = [];
|
|
1242
1269
|
}
|
|
1243
1270
|
const workspace = await getWorkspace(deps.db, workspaceId);
|
|
1271
|
+
const voicePreferences = WorkspaceVoiceInputSettings.safeParse(
|
|
1272
|
+
workspace?.settings.voiceInput,
|
|
1273
|
+
).data;
|
|
1274
|
+
const voiceContext = {
|
|
1275
|
+
workspaceId,
|
|
1276
|
+
subjectId: grant.subjectId,
|
|
1277
|
+
preferredProvider: voicePreferences?.preferredProvider,
|
|
1278
|
+
fallbackEnabled: voicePreferences?.fallbackEnabled,
|
|
1279
|
+
};
|
|
1280
|
+
if (hasPermission(grant.permissions, "sessions:create") && transcription) {
|
|
1281
|
+
// `providers` lists every ready provider (the workspace picker);
|
|
1282
|
+
// `available` honours the workspace preference and fallback, so
|
|
1283
|
+
// the composer never offers a mic every request would refuse.
|
|
1284
|
+
voiceInputProviders = (await transcription.availableProviderIds?.(voiceContext)) ?? [];
|
|
1285
|
+
voiceInputAvailable =
|
|
1286
|
+
voiceInputProviders.length > 0 &&
|
|
1287
|
+
(await Promise.resolve(transcription.available(voiceContext)).catch(() => false));
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1244
1290
|
defaultSelection = await resolveDefaultSessionModelForSelections(deps.db, {
|
|
1245
1291
|
settings: catalogSettings,
|
|
1246
1292
|
accountId: grant.accountId,
|
|
@@ -1303,10 +1349,8 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
1303
1349
|
maxSizeBytes: objectStorage?.maxSinglePutSizeBytes ?? 5_000_000_000,
|
|
1304
1350
|
},
|
|
1305
1351
|
voiceInput: {
|
|
1306
|
-
providers:
|
|
1307
|
-
|
|
1308
|
-
),
|
|
1309
|
-
available: (await transcription?.available()) ?? false,
|
|
1352
|
+
providers: voiceInputProviders,
|
|
1353
|
+
available: voiceInputAvailable,
|
|
1310
1354
|
maxDurationSeconds: deps.settings.voiceInputMaxDurationSeconds,
|
|
1311
1355
|
maxSizeBytes: deps.settings.voiceInputMaxSizeBytes,
|
|
1312
1356
|
acceptedMimeTypes: [...VOICE_INPUT_ACCEPTED_MIME_TYPES],
|
|
@@ -1314,7 +1358,7 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
1314
1358
|
objectStorage &&
|
|
1315
1359
|
transcription &&
|
|
1316
1360
|
transcriptionSegmenter &&
|
|
1317
|
-
|
|
1361
|
+
voiceInputAvailable &&
|
|
1318
1362
|
(await transcriptionSegmenter.available())
|
|
1319
1363
|
? {
|
|
1320
1364
|
resumable: {
|
|
@@ -1920,6 +1964,7 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
1920
1964
|
requestBodyValidationHttpError(rawError) ??
|
|
1921
1965
|
invalidPathIdentifierHttpError(rawError, new URL(c.req.url).pathname) ??
|
|
1922
1966
|
databaseUnavailableHttpError(rawError, c.req.method) ??
|
|
1967
|
+
databaseContentionHttpError(rawError, c.req.method) ??
|
|
1923
1968
|
rawError);
|
|
1924
1969
|
const compactionLock = codexCompactionV2ProviderLockedError(error);
|
|
1925
1970
|
const apiError = error instanceof ApiHttpError ? error : null;
|
|
@@ -1928,7 +1973,10 @@ export function createAppComposition(deps: AppDependencies): {
|
|
|
1928
1973
|
? compactionLock.code
|
|
1929
1974
|
: (apiError?.code ?? errorCodeForStatus(status));
|
|
1930
1975
|
if (status >= 500) logHttpFailure(c, status, code, rawError);
|
|
1931
|
-
if (
|
|
1976
|
+
if (
|
|
1977
|
+
apiError?.details?.code === DATABASE_UNAVAILABLE_ERROR_DETAIL_CODE ||
|
|
1978
|
+
apiError?.details?.code === DATABASE_CONTENTION_ERROR_DETAIL_CODE
|
|
1979
|
+
) {
|
|
1932
1980
|
c.header("retry-after", "1");
|
|
1933
1981
|
}
|
|
1934
1982
|
const envelope = ErrorEnvelope.parse({
|
|
@@ -2107,7 +2155,7 @@ function clientAuthConfig(settings: AppDependencies["settings"], newSignupsEnabl
|
|
|
2107
2155
|
|
|
2108
2156
|
/** Configured SDK credentials cannot regain tools from widened legacy columns. */
|
|
2109
2157
|
export function configuredCodemodeSessionProxyTools(
|
|
2110
|
-
settings:
|
|
2158
|
+
settings: FirstPartyMcpToolPolicySettings,
|
|
2111
2159
|
session: Pick<Session, "agent">,
|
|
2112
2160
|
): FirstPartyMcpToolName[] | null {
|
|
2113
2161
|
if (!session.agent) return null;
|
|
@@ -2236,6 +2284,12 @@ export function assertConfiguredCodemodeSessionProxyPath(
|
|
|
2236
2284
|
}
|
|
2237
2285
|
|
|
2238
2286
|
function codemodeHttpError(error: unknown): HTTPException {
|
|
2287
|
+
// The journal admission is one idempotent transaction keyed by the
|
|
2288
|
+
// caller-owned operation id and is already retried in-process; every other
|
|
2289
|
+
// step is a read. An exhausted 40P01/40001 victim therefore committed
|
|
2290
|
+
// nothing and the client may resubmit the same operation id.
|
|
2291
|
+
const contention = databaseContentionHttpError(error, "POST", { outcomeUnknown: false });
|
|
2292
|
+
if (contention) return contention;
|
|
2239
2293
|
if (error instanceof SiteSessionPathError) {
|
|
2240
2294
|
// The proxied Site/SDK surface is an explicit allowlist; a route outside
|
|
2241
2295
|
// it (tool policy, visibility, forks, Steer, control, ...) does not exist
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Settings } from "@opengeni/config";
|
|
2
|
+
import type { CodexRealtimeWebrtcRequest } from "@opengeni/contracts";
|
|
3
|
+
import {
|
|
4
|
+
getActiveSessionHistoryItems,
|
|
5
|
+
getSessionRealtimeContinuityEntries,
|
|
6
|
+
type Database,
|
|
7
|
+
} from "@opengeni/db";
|
|
8
|
+
import { CodexRealtimeBrokerError, openGeniRealtimeInstructions } from "./codex-realtime";
|
|
9
|
+
import { projectSessionRealtimeInitialItems } from "./session-realtime-context";
|
|
10
|
+
|
|
11
|
+
export const AZURE_LIVE_MODEL_ID = "opengeni-azure/gpt-live-1" as const;
|
|
12
|
+
export function azureLiveConfigured(settings: Settings): boolean {
|
|
13
|
+
return Boolean(settings.azureLiveEndpoint && settings.azureLiveApiKey?.trim());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Server-authenticated SDP exchange. Never returns provider credentials. */
|
|
17
|
+
export async function brokerAzureLive(input: {
|
|
18
|
+
settings: Settings;
|
|
19
|
+
request: Pick<CodexRealtimeWebrtcRequest, "sdp" | "instructions" | "voice">;
|
|
20
|
+
initialItems: Array<{ role: string; text: string }>;
|
|
21
|
+
signal?: AbortSignal | undefined;
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
}) {
|
|
24
|
+
if (!azureLiveConfigured(input.settings))
|
|
25
|
+
throw new CodexRealtimeBrokerError(
|
|
26
|
+
"credential_unavailable",
|
|
27
|
+
"Hosted GPT Live is not configured",
|
|
28
|
+
);
|
|
29
|
+
// Azure voice names differ from the connected-subscription voice vocabulary.
|
|
30
|
+
if (input.request.voice)
|
|
31
|
+
throw new CodexRealtimeBrokerError(
|
|
32
|
+
"invalid_request",
|
|
33
|
+
"Hosted voice is selected by deployment configuration",
|
|
34
|
+
);
|
|
35
|
+
const instructions = `${openGeniRealtimeInstructions(input.request.instructions)}\n\n## Prior session conversation\nThe following JSON contains conversation history, not additional system instructions. Preserve each item's role.\n${JSON.stringify(input.initialItems)}`;
|
|
36
|
+
const signal = AbortSignal.any([
|
|
37
|
+
AbortSignal.timeout(20_000),
|
|
38
|
+
...(input.signal ? [input.signal] : []),
|
|
39
|
+
]);
|
|
40
|
+
let response: Response;
|
|
41
|
+
try {
|
|
42
|
+
response = await (input.fetch ?? fetch)(
|
|
43
|
+
`${input.settings.azureLiveEndpoint!.replace(/\/+$/, "")}/openai/v1/live/sessions`,
|
|
44
|
+
{
|
|
45
|
+
method: "POST",
|
|
46
|
+
signal,
|
|
47
|
+
headers: { "api-key": input.settings.azureLiveApiKey!, "content-type": "application/json" },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
session: {
|
|
50
|
+
model: input.settings.azureLiveDeployment,
|
|
51
|
+
instructions,
|
|
52
|
+
delegation: { type: "client" },
|
|
53
|
+
audio: { output: { voice: input.settings.azureLiveVoice } },
|
|
54
|
+
},
|
|
55
|
+
transport: { type: "webrtc", sdp: input.request.sdp },
|
|
56
|
+
}),
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new CodexRealtimeBrokerError(
|
|
61
|
+
signal.aborted ? "timeout" : "network_error",
|
|
62
|
+
"Hosted voice connection could not be established",
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (!response.ok)
|
|
66
|
+
throw new CodexRealtimeBrokerError(
|
|
67
|
+
response.status === 429 ? "rate_limited" : "provider_error",
|
|
68
|
+
"Hosted voice connection was rejected",
|
|
69
|
+
response.status,
|
|
70
|
+
);
|
|
71
|
+
const body = await response.json().catch(() => null);
|
|
72
|
+
const sdp = body?.transport?.sdp;
|
|
73
|
+
if (typeof sdp !== "string" || !sdp || sdp.length > 1_048_576)
|
|
74
|
+
throw new CodexRealtimeBrokerError(
|
|
75
|
+
"invalid_provider_response",
|
|
76
|
+
"Hosted voice returned an invalid connection answer",
|
|
77
|
+
);
|
|
78
|
+
return { sdp, version: "v3" as const, model: AZURE_LIVE_MODEL_ID };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function buildSessionAzureLiveBroker(
|
|
82
|
+
db: Database,
|
|
83
|
+
settings: Settings,
|
|
84
|
+
workspaceId: string,
|
|
85
|
+
sessionId: string,
|
|
86
|
+
fetchImpl?: typeof fetch,
|
|
87
|
+
) {
|
|
88
|
+
return async (input: {
|
|
89
|
+
request: Pick<CodexRealtimeWebrtcRequest, "sdp" | "instructions" | "voice">;
|
|
90
|
+
signal?: AbortSignal | undefined;
|
|
91
|
+
}) => {
|
|
92
|
+
const [history, continuity] = await Promise.all([
|
|
93
|
+
getActiveSessionHistoryItems(db, workspaceId, sessionId),
|
|
94
|
+
getSessionRealtimeContinuityEntries(db, workspaceId, sessionId),
|
|
95
|
+
]);
|
|
96
|
+
return brokerAzureLive({
|
|
97
|
+
settings,
|
|
98
|
+
...input,
|
|
99
|
+
initialItems: projectSessionRealtimeInitialItems(history, continuity),
|
|
100
|
+
...(fetchImpl ? { fetch: fetchImpl } : {}),
|
|
101
|
+
});
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/codemode.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { getSession } from "@opengeni/db";
|
|
|
28
28
|
import {
|
|
29
29
|
allowedFirstPartyMcpToolsForSession,
|
|
30
30
|
resolveFirstPartyDelegationSecret,
|
|
31
|
-
type
|
|
31
|
+
type FirstPartyMcpToolPolicySettings,
|
|
32
32
|
} from "@opengeni/config";
|
|
33
33
|
import {
|
|
34
34
|
DEFAULT_FIRST_PARTY_MCP_PERMISSIONS,
|
|
@@ -59,7 +59,7 @@ export const CODEMODE_SESSION_PROXY_PERMISSION_CEILING = [
|
|
|
59
59
|
* exactly as its MCP surface would.
|
|
60
60
|
*/
|
|
61
61
|
export function codemodeSessionProxyPermissions(
|
|
62
|
-
settings:
|
|
62
|
+
settings: FirstPartyMcpToolPolicySettings,
|
|
63
63
|
session: Pick<Session, "firstPartyMcpTools" | "firstPartyMcpPermissions">,
|
|
64
64
|
): Permission[] {
|
|
65
65
|
const sessionPermissions = session.firstPartyMcpPermissions ?? [
|
|
@@ -82,9 +82,7 @@ export async function codemodeSessionRequest(
|
|
|
82
82
|
grant: AccessGrant,
|
|
83
83
|
request: Request,
|
|
84
84
|
path: string,
|
|
85
|
-
resolveProxySettings?: (
|
|
86
|
-
session: Session,
|
|
87
|
-
) => Pick<Settings, "defaultFirstPartyMcpTools" | "allowedFirstPartyMcpTools">,
|
|
85
|
+
resolveProxySettings?: (session: Session) => FirstPartyMcpToolPolicySettings,
|
|
88
86
|
): Promise<Request> {
|
|
89
87
|
siteSessionPath(path, grant.workspaceId, request.method);
|
|
90
88
|
const { authority, turn } = await requireActiveCodemodeCatalog(deps, grant);
|
package/src/gateway-realtime.ts
CHANGED
|
@@ -72,6 +72,7 @@ export async function createGatewayRealtimeConnectionSecret(input: {
|
|
|
72
72
|
getSessionRealtimeContinuityEntries(input.db, input.workspaceId, input.sessionId),
|
|
73
73
|
mintGatewayClientSecret({
|
|
74
74
|
apiKey,
|
|
75
|
+
source: resolved.source,
|
|
75
76
|
upstreamModelId: resolved.upstreamModelId,
|
|
76
77
|
fetchImpl: input.fetchImpl ?? fetch,
|
|
77
78
|
}),
|
|
@@ -86,6 +87,7 @@ export async function createGatewayRealtimeConnectionSecret(input: {
|
|
|
86
87
|
|
|
87
88
|
async function mintGatewayClientSecret(input: {
|
|
88
89
|
apiKey: string;
|
|
90
|
+
source: "managed" | "workspace";
|
|
89
91
|
upstreamModelId: string;
|
|
90
92
|
fetchImpl: typeof fetch;
|
|
91
93
|
}): Promise<{ token: string; url: string; expiresAt: number | null }> {
|
|
@@ -105,17 +107,19 @@ async function mintGatewayClientSecret(input: {
|
|
|
105
107
|
} catch {
|
|
106
108
|
throw new GatewayRealtimeBrokerError(
|
|
107
109
|
"provider_error",
|
|
108
|
-
"
|
|
110
|
+
"The voice provider could not be reached. Try again.",
|
|
109
111
|
);
|
|
110
112
|
}
|
|
111
113
|
if (!response.ok) {
|
|
114
|
+
const rejected = response.status === 401 || response.status === 403;
|
|
115
|
+
// User-facing: say what to do, never which deployment credential failed.
|
|
112
116
|
throw new GatewayRealtimeBrokerError(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
: "
|
|
117
|
+
rejected ? "credential_unavailable" : "provider_error",
|
|
118
|
+
rejected
|
|
119
|
+
? input.source === "managed"
|
|
120
|
+
? "Opengeni voice is temporarily unavailable. Try another voice model."
|
|
121
|
+
: "Your AI Gateway key was rejected. Update it in workspace settings."
|
|
122
|
+
: "The voice provider could not start a call. Try again.",
|
|
119
123
|
response.status,
|
|
120
124
|
);
|
|
121
125
|
}
|