@@ -17,21 +17,214 @@ export interface TangleModelConfig {
1717 baseUrl : string
1818}
1919
20+ export type TangleExecutionEnvironment = 'development' | 'staging' | 'production' | 'test'
21+ export type TangleExecutionKeySource = 'local-env' | 'user'
22+ export type TangleExecutionKeyErrorCode =
23+ | 'local_tangle_api_key_required'
24+ | 'tangle_account_not_connected'
25+
2026export interface ResolveModelOptions {
2127 /** Env to read (defaults to process.env). */
2228 env ?: Record < string , string | undefined >
2329 /** Router base URL default when `TANGLE_ROUTER_BASE_URL` is unset. */
2430 defaultRouterBaseUrl ?: string
2531}
2632
33+ export interface ResolveUserTangleExecutionKeyOptions {
34+ /** Deployment context. Only local development may fall back to env keys. */
35+ environment ?: TangleExecutionEnvironment
36+ /** Env to read for the local-development fallback. */
37+ env ?: Record < string , string | undefined >
38+ /** App-owned lookup for the caller's linked platform API key. */
39+ getUserApiKey : ( ) => string | null | undefined | Promise < string | null | undefined >
40+ }
41+
42+ export interface ResolveUserTangleExecutionKeyForUserOptions < UserId = string > {
43+ userId : UserId
44+ environment ?: TangleExecutionEnvironment
45+ env ?: Record < string , string | undefined >
46+ getUserApiKey : ( userId : UserId ) => string | null | undefined | Promise < string | null | undefined >
47+ }
48+
49+ export interface ResolvedTangleExecutionKey {
50+ apiKey : string
51+ source : TangleExecutionKeySource
52+ }
53+
54+ export interface TangleExecutionKeyHttpError {
55+ status : number
56+ body : {
57+ error : string
58+ code : TangleExecutionKeyErrorCode
59+ }
60+ }
61+
62+ export interface CreateTangleRouterModelConfigOptions {
63+ apiKey : string
64+ model : string
65+ baseUrl ?: string
66+ }
67+
68+ export interface TangleBillingEnforcementOptions {
69+ /** Env to read (defaults to process.env). */
70+ env ?: Record < string , string | undefined >
71+ /**
72+ * Optional app-specific override flag, e.g. `GTM_BILLING_ENFORCEMENT`.
73+ * Defaults to the shared `TANGLE_BILLING_ENFORCEMENT`.
74+ */
75+ enforcementEnvVar ?: string
76+ }
77+
2778export const DEFAULT_TANGLE_ROUTER_BASE_URL = 'https://router.tangle.tools/v1'
79+ export const DEFAULT_TANGLE_BILLING_ENFORCEMENT_ENV_VAR = 'TANGLE_BILLING_ENFORCEMENT'
2880
2981function requireEnv ( env : Record < string , string | undefined > , name : string ) : string {
3082 const value = env [ name ] ?. trim ( )
3183 if ( ! value ) throw new Error ( `${ name } is required` )
3284 return value
3385}
3486
87+ function trimOrNull ( value : string | null | undefined ) : string | null {
88+ const trimmed = value ?. trim ( )
89+ return trimmed ? trimmed : null
90+ }
91+
92+ function isTangleExecutionKeyErrorCode ( value : unknown ) : value is TangleExecutionKeyErrorCode {
93+ return value === 'local_tangle_api_key_required' || value === 'tangle_account_not_connected'
94+ }
95+
96+ export class TangleExecutionKeyError extends Error {
97+ readonly code : TangleExecutionKeyErrorCode
98+ readonly status : number
99+
100+ constructor ( code : TangleExecutionKeyErrorCode , message : string , status : number ) {
101+ super ( message )
102+ this . name = 'TangleExecutionKeyError'
103+ this . code = code
104+ this . status = status
105+ }
106+ }
107+
108+ export function isTangleExecutionKeyError ( error : unknown ) : error is TangleExecutionKeyError {
109+ return error instanceof TangleExecutionKeyError
110+ || (
111+ typeof error === 'object'
112+ && error !== null
113+ && ( error as { name ?: unknown } ) . name === 'TangleExecutionKeyError'
114+ && typeof ( error as { message ?: unknown } ) . message === 'string'
115+ && isTangleExecutionKeyErrorCode ( ( error as { code ?: unknown } ) . code )
116+ && typeof ( error as { status ?: unknown } ) . status === 'number'
117+ )
118+ }
119+
120+ export function resolveTangleExecutionEnvironment (
121+ env : Record < string , string | undefined > = process . env as Record < string , string | undefined > ,
122+ ) : TangleExecutionEnvironment {
123+ const raw = ( env . APP_ENV ?? env . NODE_ENV ?? '' ) . trim ( ) . toLowerCase ( )
124+ if ( raw === 'development' || raw === 'dev' || raw === 'local' ) return 'development'
125+ if ( raw === 'staging' ) return 'staging'
126+ if ( raw === 'test' ) return 'test'
127+ return 'production'
128+ }
129+
130+ /**
131+ * Shared policy for agent products that bill through the Tangle Platform.
132+ *
133+ * Local development defaults billing enforcement off so apps can use a local
134+ * `TANGLE_API_KEY` without requiring a browser-linked platform account. Any
135+ * non-development environment defaults enforcement on. Apps may pass their own
136+ * override flag (`FOO_BILLING_ENFORCEMENT`) while new apps can use the shared
137+ * `TANGLE_BILLING_ENFORCEMENT`.
138+ */
139+ export function isTangleBillingEnforcementDisabled (
140+ opts : TangleBillingEnforcementOptions = { } ,
141+ ) : boolean {
142+ const env = opts . env ?? ( process . env as Record < string , string | undefined > )
143+ const enforcementEnvVar = opts . enforcementEnvVar ?? DEFAULT_TANGLE_BILLING_ENFORCEMENT_ENV_VAR
144+ const override = env [ enforcementEnvVar ] ?. trim ( ) . toLowerCase ( )
145+
146+ if ( override === 'disabled' ) return true
147+ if ( override === 'enabled' ) return false
148+
149+ return resolveTangleExecutionEnvironment ( env ) === 'development'
150+ }
151+
152+ export function tangleExecutionKeyHttpError ( error : unknown ) : TangleExecutionKeyHttpError | null {
153+ if ( ! isTangleExecutionKeyError ( error ) ) return null
154+ return {
155+ status : error . status ,
156+ body : {
157+ error : error . message ,
158+ code : error . code ,
159+ } ,
160+ }
161+ }
162+
163+ /**
164+ * Resolve the user-facing Tangle API key for model execution.
165+ *
166+ * Local development may use a server env key so apps remain easy to run.
167+ * Deployed contexts must use the caller's linked platform key; this keeps
168+ * model execution, billing, and account ownership aligned across products.
169+ */
170+ export async function resolveUserTangleExecutionKey (
171+ opts : ResolveUserTangleExecutionKeyOptions ,
172+ ) : Promise < ResolvedTangleExecutionKey > {
173+ const env = opts . env ?? ( process . env as Record < string , string | undefined > )
174+ const environment = opts . environment ?? resolveTangleExecutionEnvironment ( env )
175+
176+ if ( environment === 'development' ) {
177+ const apiKey = trimOrNull ( env . TANGLE_API_KEY )
178+ if ( apiKey ) return { apiKey, source : 'local-env' }
179+ }
180+
181+ const apiKey = trimOrNull ( await opts . getUserApiKey ( ) )
182+ if ( apiKey ) return { apiKey, source : 'user' }
183+
184+ if ( environment === 'development' ) {
185+ throw new TangleExecutionKeyError (
186+ 'local_tangle_api_key_required' ,
187+ 'TANGLE_API_KEY or a linked Tangle account is required for local Tangle model execution.' ,
188+ 503 ,
189+ )
190+ }
191+
192+ throw new TangleExecutionKeyError (
193+ 'tangle_account_not_connected' ,
194+ 'Connect your Tangle account before invoking this agent.' ,
195+ 401 ,
196+ )
197+ }
198+
199+ export async function resolveUserTangleExecutionKeyForUser < UserId = string > (
200+ opts : ResolveUserTangleExecutionKeyForUserOptions < UserId > ,
201+ ) : Promise < ResolvedTangleExecutionKey > {
202+ return resolveUserTangleExecutionKey ( {
203+ environment : opts . environment ,
204+ env : opts . env ,
205+ getUserApiKey : ( ) => opts . getUserApiKey ( opts . userId ) ,
206+ } )
207+ }
208+
209+ /**
210+ * Build an OpenAI-compatible Tangle Router model config from an already
211+ * resolved execution key. This intentionally does not read TANGLE_API_KEY.
212+ */
213+ export function createTangleRouterModelConfig (
214+ opts : CreateTangleRouterModelConfigOptions ,
215+ ) : TangleModelConfig {
216+ const apiKey = opts . apiKey . trim ( )
217+ if ( ! apiKey ) throw new Error ( 'apiKey is required' )
218+ const model = opts . model . trim ( )
219+ if ( ! model ) throw new Error ( 'model is required' )
220+ return {
221+ provider : 'openai-compat' ,
222+ model,
223+ apiKey,
224+ baseUrl : ( opts . baseUrl ?. trim ( ) || DEFAULT_TANGLE_ROUTER_BASE_URL ) . replace ( / \/ + $ / , '' ) ,
225+ }
226+ }
227+
35228/**
36229 * Resolve the model config from env. DEFAULT path (`MODEL_PROVIDER` unset or
37230 * `openai-compat`/`tangle-router`/`tcloud`): the Tangle Router, authenticated
0 commit comments