diff --git a/README.md b/README.md index 92217fa..49e8440 100644 --- a/README.md +++ b/README.md @@ -419,6 +419,79 @@ If you need a different layout, keep passing `sourceDir` and `compiledDir` expli `renderPrompt()` and `validatePrompt()` use the same source-versus-compiled resolution rules as `kit.renderPrompt()`. The existing synchronous `render()` and `validate()` methods still work for already-resolved compiled or inline assets. +## UsageTap LLM Gateway + +Select `provider: usagetap` for the first-party OpenAI-compatible gateway. The default base URL is +`https://gateway.usagetap.com/v1` (including `/v1`) and the default managed route is +`usagetap/standard`. `usagetap/premium`, direct canonical `provider/model` IDs, and ordered +`fallback_models` are supported. Customer attribution is optional; without it, calls are attributed +to the authenticated organization. Pass the API key only in runtime options, never prompt assets. + +```ts +import OpenAI from 'openai'; +import { createUsageTapGatewayOpenAIConfig, usagetapAdapter } from 'promptopskit/usagetap'; + +const apiKey = process.env.USAGETAP_GATEWAY_API_KEY!; +const openai = new OpenAI(createUsageTapGatewayOpenAIConfig({ apiKey })); +const request = await usagetapAdapter.renderPrompt({ source: `--- +id: buffered-gateway +provider: usagetap +model: usagetap/standard +fallback_models: [usagetap/premium, openai/gpt-5-mini] +provider_options: + usagetap: + feature: prompt-tightener + compress: { mode: deterministic, aggressiveness: 0.35 } +--- +# Prompt template +Tighten: {{ prompt }} +` }, { variables: { prompt: 'Summarize the report.' }, usagetap: { apiKey, idempotencyKey: 'request-1' } }); +if (!('body' in request)) throw new Error(request.returnMessage); +await openai.chat.completions.create(request.body as never); +``` + +Gateway compression uses `provider_options.usagetap.compress` or `raw.usagetap.compress` and is +separate from PromptOpsKit's local/client compression pipeline. Never wrap gateway calls with +`beginUsageTapCall`, `withUsageTapCall`, or a `runOpenAIWithUsageTap`-style helper: the gateway +meters itself. `createUsageTapClient` and those helpers meter direct calls to other providers. + +For manual transport, append `/chat/completions`, producing +`https://gateway.usagetap.com/v1/chat/completions`; do not append a second `/v1`. + +```diff +- provider: llmasaservice +- model: group:standard ++ provider: usagetap ++ model: usagetap/standard + raw: +- llmasaservice: ++ usagetap: + llmGateway: + feature: prompt-tightener +``` + +```diff +- import { llmasaserviceAdapter, LLMASASERVICE_BASE_URL } from "promptopskit"; ++ import { usagetapAdapter, USAGETAP_GATEWAY_BASE_URL } from "promptopskit"; +- const request = llmasaserviceAdapter.render(asset, { +- llmasaservice: { apiKey }, ++ const request = usagetapAdapter.render(asset, { ++ usagetap: { apiKey, idempotencyKey }, + runtime: { +- model: "group:standard", ++ model: "usagetap/standard", + provider_options: { +- llmasaservice: { base_url: baseURL, customer }, ++ usagetap: { base_url: baseURL, customer }, + }, + }, + }); +``` + +Consumer applications should explicitly rename `LLMASASERVICE_API_KEY` to +`USAGETAP_GATEWAY_API_KEY`, `LLMASASERVICE_BASE_URL` to `USAGETAP_GATEWAY_BASE_URL`, and +`LLMASASERVICE_MODEL` to `USAGETAP_GATEWAY_MODEL`. Migrating downstream consumers is separate. + ## Optional UsageTap Tracking PromptOpsKit can also help you track provider calls with UsageTap.com while keeping the core render API transport-light. diff --git a/SKILL.md b/SKILL.md index ae68f97..d49c85a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -135,6 +135,7 @@ Provider aliases: | `google`, `gemini` | `gemini` | | `openrouter` | `openrouter` | | `llmasaservice`, `llmasaservice.io`, `llm gateway` | `llmasaservice` | +| `usagetap`, `usagetap gateway`, `gateway.usagetap.com` | `usagetap` | Behavior: @@ -259,6 +260,39 @@ if (!result.request) throw new Error('Prompt rendering did not produce an OpenRo const completion = await client.chat.completions.create(result.request.body as any); ``` +UsageTap gateway example (buffered Chat Completions): + +```ts +import OpenAI from 'openai'; +import { createUsageTapGatewayOpenAIConfig, usagetapAdapter } from 'promptopskit/usagetap'; + +const apiKey = process.env.USAGETAP_GATEWAY_API_KEY!; +const client = new OpenAI(createUsageTapGatewayOpenAIConfig({ apiKey })); +const rendered = await usagetapAdapter.renderPrompt({ source: `--- +id: usagetap-example +provider: usagetap +model: usagetap/standard +fallback_models: [usagetap/premium, openai/gpt-5-mini] +provider_options: + usagetap: + feature: prompt-tightener + compress: { mode: deterministic, aggressiveness: 0.35 } +--- +# Prompt template +Improve {{ text }} +` }, { variables: { text: 'this prompt' }, usagetap: { apiKey } }); +if (!('body' in rendered)) throw new Error(rendered.returnMessage); +await client.chat.completions.create(rendered.body as never); +``` + +The default URL is `https://gateway.usagetap.com/v1`; append only `/chat/completions` for manual +transport. `usagetap/standard` and `usagetap/premium` are managed aliases; direct `provider/model` +IDs and ordered fallbacks work too. The API key is runtime-only and customer attribution is +optional. Gateway compression (`provider_options.usagetap.compress` or `raw.usagetap.compress`) is +separate from local/client compression. Never combine `usagetapAdapter` with client-side UsageTap +begin/end or runner helpers because the gateway meters itself. Generic UsageTap lifecycle requests +still refer to `createUsageTapClient` and direct-provider metering helpers. + LLMAsAService example: ```typescript diff --git a/docs/providers.md b/docs/providers.md index 85d7a42..38d0a62 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -476,6 +476,24 @@ provider_options: Use `raw.openrouter` for less common OpenRouter body fields that PromptOpsKit does not model yet. +## UsageTap Gateway + +Use `provider: usagetap` with `usagetapAdapter` for the first-party UsageTap gateway. It renders +OpenAI Chat Completions bodies with bearer authentication. The default model is +`usagetap/standard` and the default base URL is `https://gateway.usagetap.com/v1`; managed +`usagetap/premium`, canonical `provider/model` IDs, and ordered `fallback_models` are supported. +Customer attribution is optional. Supply `{ usagetap: { apiKey, idempotencyKey? } }` only at render +time. Typed gateway metadata and compression live under `provider_options.usagetap`; unsupported +fields can use `raw.usagetap`, which merges last. + +Gateway calls are already authorized and metered by UsageTap. Do not wrap them in client-side +`beginUsageTapCall`/`endUsageTapCall`, `withUsageTapCall`, or provider runner helpers. Those lifecycle +APIs remain for metering direct calls made to other providers. Gateway compression is also distinct +from PromptOpsKit's local/client prompt compression pipeline. + +Manual callers append `/chat/completions` to get +`https://gateway.usagetap.com/v1/chat/completions`, without adding another `/v1`. + ## LLMAsAService Gateway Body shape: OpenAI-compatible Chat Completions payloads sent to `https://gateway.llmasaservice.io`. The adapter reuses the OpenAI chat mapping, applies `provider_options.llmasaservice`, and reads `raw.llmasaservice` for gateway-only body fields. diff --git a/src/index.ts b/src/index.ts index fae19a5..f7fdb71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export type { RuntimeHistoryMessage, OpenAIResponsesRuntimeOptions, LLMAsAServiceRuntimeOptions, + UsageTapGatewayRuntimeOptions, ProviderAdapter, ProviderInlinePromptSource, ProviderPromptInput, @@ -91,6 +92,14 @@ export { createLLMAsAServiceOpenAIConfig, llmasaserviceAdapter, } from './providers/llmasaservice.js'; +export { + USAGETAP_GATEWAY_BASE_URL, + USAGETAP_GATEWAY_DEFAULT_MODEL, + USAGETAP_GATEWAY_RESPONSE_HEADER_NAMES, + createUsageTapGatewayOpenAIConfig, + usagetapAdapter, +} from './providers/usagetap.js'; +export type { UsageTapGatewayOpenAIConfig, UsageTapGatewayOpenAIConfigOptions } from './providers/usagetap.js'; export { PromptAssetSchema, PromptAssetOverridesSchema } from './schema/index.js'; export { summarizePromptCompression, @@ -157,6 +166,8 @@ export interface RenderPromptOptions { openaiResponses?: RuntimeRenderOptions['openaiResponses']; /** LLMAsAService gateway credentials */ llmasaservice?: RuntimeRenderOptions['llmasaservice']; + /** UsageTap gateway credentials and optional per-request idempotency key */ + usagetap?: RuntimeRenderOptions['usagetap']; /** TheTokenCompany compression credentials and transport options */ theTokenCompany?: RuntimeRenderOptions['theTokenCompany']; } @@ -268,6 +279,7 @@ export class PromptOpsKit { const validation = adapter.validate(resolved, { openaiResponses: options.openaiResponses, llmasaservice: options.llmasaservice, + usagetap: options.usagetap, }); if (!validation.valid) { @@ -312,6 +324,7 @@ export class PromptOpsKit { strict: options.strict, openaiResponses: options.openaiResponses, llmasaservice: options.llmasaservice, + usagetap: options.usagetap, theTokenCompany: options.theTokenCompany, }); const request = adapter.render(prepared.asset, prepared.runtime); diff --git a/src/overrides/apply-overrides.ts b/src/overrides/apply-overrides.ts index 40e80dc..b0b1482 100644 --- a/src/overrides/apply-overrides.ts +++ b/src/overrides/apply-overrides.ts @@ -95,6 +95,7 @@ function mergeOverride( google: mergeRecordBlock(result.raw?.google, override.raw.google), openrouter: mergeRecordBlock(result.raw?.openrouter, override.raw.openrouter), llmasaservice: mergeRecordBlock(result.raw?.llmasaservice, override.raw.llmasaservice), + usagetap: mergeRecordBlock(result.raw?.usagetap, override.raw.usagetap), }; } @@ -118,6 +119,10 @@ function mergeOverride( ...result.provider_options?.llmasaservice, ...override.provider_options.llmasaservice, }, + usagetap: { + ...result.provider_options?.usagetap, + ...override.provider_options.usagetap, + }, }; } diff --git a/src/parser/loader.ts b/src/parser/loader.ts index 3289010..24c334b 100644 --- a/src/parser/loader.ts +++ b/src/parser/loader.ts @@ -294,6 +294,7 @@ function mergeRaw(base: PromptDefaults['raw'], local: PromptDefaults['raw']): Pr google: mergeRecordBlock(base?.google, local?.google), openrouter: mergeRecordBlock(base?.openrouter, local?.openrouter), llmasaservice: mergeRecordBlock(base?.llmasaservice, local?.llmasaservice), + usagetap: mergeRecordBlock(base?.usagetap, local?.usagetap), }; removeEmptyProviderBlocks(merged); @@ -311,6 +312,7 @@ function mergeProviderOptions( gemini: mergeRecordBlock(base?.gemini, local?.gemini), openrouter: mergeRecordBlock(base?.openrouter, local?.openrouter), llmasaservice: mergeRecordBlock(base?.llmasaservice, local?.llmasaservice), + usagetap: mergeRecordBlock(base?.usagetap, local?.usagetap), }; removeEmptyProviderBlocks(merged); diff --git a/src/providers/index.ts b/src/providers/index.ts index 0224adf..d803e4e 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -7,6 +7,7 @@ export type { ValidationResult, RuntimeRenderOptions, LLMAsAServiceRuntimeOptions, + UsageTapGatewayRuntimeOptions, } from './types.js'; export { openaiAdapter } from './openai.js'; export { openaiResponsesAdapter } from './openai-responses.js'; @@ -20,6 +21,14 @@ export { createLLMAsAServiceOpenAIConfig, llmasaserviceAdapter, } from './llmasaservice.js'; +export { + USAGETAP_GATEWAY_BASE_URL, + USAGETAP_GATEWAY_DEFAULT_MODEL, + USAGETAP_GATEWAY_RESPONSE_HEADER_NAMES, + createUsageTapGatewayOpenAIConfig, + usagetapAdapter, +} from './usagetap.js'; +export type { UsageTapGatewayOpenAIConfig, UsageTapGatewayOpenAIConfigOptions } from './usagetap.js'; import type { ProviderAdapter } from './types.js'; import { openaiAdapter } from './openai.js'; @@ -28,6 +37,7 @@ import { anthropicAdapter } from './anthropic.js'; import { geminiAdapter } from './gemini.js'; import { openrouterAdapter } from './openrouter.js'; import { llmasaserviceAdapter } from './llmasaservice.js'; +import { usagetapAdapter } from './usagetap.js'; const adapters: Record = { openai: openaiAdapter, @@ -37,6 +47,7 @@ const adapters: Record = { gemini: geminiAdapter, openrouter: openrouterAdapter, llmasaservice: llmasaserviceAdapter, + usagetap: usagetapAdapter, }; /** diff --git a/src/providers/raw.ts b/src/providers/raw.ts index 5d3fdcf..ea52942 100644 --- a/src/providers/raw.ts +++ b/src/providers/raw.ts @@ -3,7 +3,7 @@ import type { ResolvedPromptAsset } from '../schema/index.js'; export function applyRawProviderBody( body: Record, asset: ResolvedPromptAsset, - provider: 'openai' | 'openai-responses' | 'anthropic' | 'gemini' | 'openrouter' | 'llmasaservice', + provider: 'openai' | 'openai-responses' | 'anthropic' | 'gemini' | 'openrouter' | 'llmasaservice' | 'usagetap', ): Record { const raw = getRawProviderBody(asset, provider); return raw ? { ...body, ...raw } : body; @@ -11,7 +11,7 @@ export function applyRawProviderBody( function getRawProviderBody( asset: ResolvedPromptAsset, - provider: 'openai' | 'openai-responses' | 'anthropic' | 'gemini' | 'openrouter' | 'llmasaservice', + provider: 'openai' | 'openai-responses' | 'anthropic' | 'gemini' | 'openrouter' | 'llmasaservice' | 'usagetap', ): Record | undefined { if (provider === 'openai-responses') { return asset.raw?.['openai-responses'] ?? asset.raw?.openai_responses; diff --git a/src/providers/types.ts b/src/providers/types.ts index 3b799dc..333387e 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -58,6 +58,12 @@ export interface LLMAsAServiceRuntimeOptions { apiKey: string; } +/** Credentials and per-request metadata for the UsageTap gateway. */ +export interface UsageTapGatewayRuntimeOptions { + apiKey: string; + idempotencyKey?: string; +} + export interface RuntimeHistoryMessage { role: string; content: string; @@ -94,6 +100,7 @@ export interface RuntimeRenderOptions { strict?: boolean; openaiResponses?: OpenAIResponsesRuntimeOptions; llmasaservice?: LLMAsAServiceRuntimeOptions; + usagetap?: UsageTapGatewayRuntimeOptions; theTokenCompany?: TheTokenCompanyRuntimeOptions; } diff --git a/src/providers/usagetap.ts b/src/providers/usagetap.ts new file mode 100644 index 0000000..7b9b75c --- /dev/null +++ b/src/providers/usagetap.ts @@ -0,0 +1,109 @@ +import { openaiAdapter } from './openai.js'; +import type { ResolvedPromptAsset } from '../schema/index.js'; +import type { ProviderAdapter, ProviderRequest, RuntimeRenderOptions, ValidationResult } from './types.js'; +import { applyRawProviderBody } from './raw.js'; +import { resolveAssetForProvider } from './resolve-asset.js'; +import { withPromptInputSupport } from './prompt-input.js'; + +export const USAGETAP_GATEWAY_BASE_URL = 'https://gateway.usagetap.com/v1'; +export const USAGETAP_GATEWAY_DEFAULT_MODEL = 'usagetap/standard'; +export const USAGETAP_GATEWAY_RESPONSE_HEADER_NAMES = [ + 'x-usagetap-call-id', + 'x-llm-model-key', + 'x-request-id', + 'x-usagetap-compression-tokens-saved', + 'x-usagetap-compression-reduction', +] as const; + +export interface UsageTapGatewayOpenAIConfig { + baseURL: string; + apiKey: string; +} + +export interface UsageTapGatewayOpenAIConfigOptions { + apiKey: string; + baseURL?: string; +} + +export function createUsageTapGatewayOpenAIConfig( + options: UsageTapGatewayOpenAIConfigOptions, +): UsageTapGatewayOpenAIConfig { + return { baseURL: options.baseURL ?? USAGETAP_GATEWAY_BASE_URL, apiKey: options.apiKey }; +} + +function isGPT5Model(model: string): boolean { + return model.split(/[|,]/).some((candidate) => + /^(?:gpt-5|openai[:/]gpt-5)/.test(candidate.trim()), + ); +} + +/** OpenAI Chat Completions adapter for the first-party UsageTap gateway. */ +export const usagetapAdapter: ProviderAdapter = withPromptInputSupport({ + name: 'usagetap', + + validate(asset: ResolvedPromptAsset, runtime?: RuntimeRenderOptions): ValidationResult { + const resolved = resolveAssetForProvider(asset, runtime); + const validation = openaiAdapter.validate( + { ...resolved, model: resolved.model ?? USAGETAP_GATEWAY_DEFAULT_MODEL }, + runtime, + ); + const warnings = validation.warnings.filter( + (warning) => warning !== 'OpenAI adapter requires a model to be specified.', + ); + const errors = [...validation.errors]; + if (!runtime?.usagetap?.apiKey) { + if (runtime === undefined) { + warnings.push('UsageTap gateway apiKey must be supplied before rendering.'); + } else { + errors.push('UsageTap gateway adapter requires usagetap.apiKey for Authorization Bearer authentication.'); + } + } + return { valid: errors.length === 0, errors, warnings }; + }, + + render(asset: ResolvedPromptAsset, runtime: RuntimeRenderOptions): ProviderRequest { + const resolved = resolveAssetForProvider(asset, runtime); + const options = resolved.provider_options?.usagetap; + const model = resolved.model ?? USAGETAP_GATEWAY_DEFAULT_MODEL; + const rendered = openaiAdapter.render( + { ...resolved, model, raw: undefined }, + { + variables: runtime.variables, + history: runtime.history, + onHistoryCompaction: runtime.onHistoryCompaction, + toolRegistry: runtime.toolRegistry, + strict: runtime.strict, + }, + ); + let body = { ...rendered.body }; + + if (isGPT5Model(model) && body.max_tokens !== undefined && body.max_completion_tokens === undefined) { + body.max_completion_tokens = body.max_tokens; + delete body.max_tokens; + } + const fallbacks = resolved.fallback_models?.filter((candidate) => candidate !== model); + if (fallbacks?.length) body.models = [...new Set(fallbacks)]; + if (options?.customer !== undefined) body.customer = options.customer; + if (options?.feature !== undefined) body.feature = options.feature; + if (options?.conversationId !== undefined) body.conversationId = options.conversationId; + if (options?.conversationTitle !== undefined) body.conversationTitle = options.conversationTitle; + if (options?.projectId !== undefined) body.projectId = options.projectId; + if (options?.compress !== undefined) body.compress = options.compress; + body = applyRawProviderBody(body, resolved, 'usagetap'); + + const credentials = runtime.usagetap; + if (!credentials?.apiKey) { + throw new Error('UsageTap gateway adapter requires usagetap.apiKey for Authorization Bearer authentication.'); + } + return { + provider: 'usagetap', + model, + baseURL: options?.base_url ?? USAGETAP_GATEWAY_BASE_URL, + headers: { + Authorization: `Bearer ${credentials.apiKey}`, + ...(credentials.idempotencyKey ? { 'Idempotency-Key': credentials.idempotencyKey } : {}), + }, + body, + }; + }, +}); diff --git a/src/schema/index.ts b/src/schema/index.ts index d740158..4f164e6 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -19,6 +19,9 @@ export { OpenRouterProviderOptionsSchema, LLMAsAServiceCustomerSchema, LLMAsAServiceProviderOptionsSchema, + UsageTapGatewayCustomerSchema, + UsageTapGatewayCompressSchema, + UsageTapGatewayProviderOptionsSchema, ProviderOptionsSchema, ContextSchema, ContextInputDefinitionSchema, diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 162f87b..e4f3e7f 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -114,11 +114,41 @@ export const LLMAsAServiceProviderOptionsSchema = z.object({ projectId: z.string().optional(), }); +export const UsageTapGatewayCustomerSchema = z.object({ + customer_id: z.string().optional(), + customer_name: z.string().optional(), + customer_email: z.string().optional(), + customer_user_id: z.string().optional(), + customer_user_name: z.string().optional(), + customer_user_email: z.string().optional(), +}); + +export const UsageTapGatewayCompressSchema = z.object({ + mode: z.string().optional(), + aggressiveness: z.number().min(0).max(1).optional(), + latencyBudgetMs: z.number().int().nonnegative().optional(), + compactEmptyUserMessages: z.boolean().optional(), + compactDuplicateUserTextParts: z.boolean().optional(), + failOpen: z.boolean().optional(), + timeoutMs: z.number().int().nonnegative().optional(), +}).passthrough(); + +export const UsageTapGatewayProviderOptionsSchema = z.object({ + base_url: z.string().url().optional(), + customer: UsageTapGatewayCustomerSchema.optional(), + feature: z.string().optional(), + conversationId: z.string().optional(), + conversationTitle: z.string().optional(), + projectId: z.string().optional(), + compress: UsageTapGatewayCompressSchema.optional(), +}); + export const ProviderOptionsSchema = z.object({ anthropic: AnthropicProviderOptionsSchema.optional(), gemini: GeminiProviderOptionsSchema.optional(), openrouter: OpenRouterProviderOptionsSchema.optional(), llmasaservice: LLMAsAServiceProviderOptionsSchema.optional(), + usagetap: UsageTapGatewayProviderOptionsSchema.optional(), }); export type ProviderOptions = z.infer; @@ -134,6 +164,7 @@ export const RawProviderBodySchema = z.object({ google: z.record(z.unknown()).optional(), openrouter: z.record(z.unknown()).optional(), llmasaservice: z.record(z.unknown()).optional(), + usagetap: z.record(z.unknown()).optional(), }); export type RawProviderBody = z.infer; @@ -313,7 +344,7 @@ export const SectionsSchema = z.object({ // --- Defaults files (folder-level inheritance) --- export const PromptDefaultsSchema = z.object({ - provider: z.enum(['openai', 'openai-responses', 'anthropic', 'google', 'gemini', 'openrouter', 'llmasaservice', 'any']).optional(), + provider: z.enum(['openai', 'openai-responses', 'anthropic', 'google', 'gemini', 'openrouter', 'llmasaservice', 'usagetap', 'any']).optional(), model: z.string().optional(), fallback_models: z.array(z.string()).optional(), reasoning: ReasoningSchema.optional(), @@ -344,7 +375,7 @@ export const PromptAssetSchema = z.object({ schema_version: z.number().int().positive().default(1), description: z.string().optional(), - provider: z.enum(['openai', 'openai-responses', 'anthropic', 'google', 'gemini', 'openrouter', 'llmasaservice', 'any']).optional(), + provider: z.enum(['openai', 'openai-responses', 'anthropic', 'google', 'gemini', 'openrouter', 'llmasaservice', 'usagetap', 'any']).optional(), model: z.string().optional(), fallback_models: z.array(z.string()).optional(), diff --git a/src/usagetap/index.ts b/src/usagetap/index.ts index 3ea8420..87ca837 100644 --- a/src/usagetap/index.ts +++ b/src/usagetap/index.ts @@ -20,6 +20,18 @@ export type { UsageTapReasoningLevel, } from './types.js'; export { createUsageTapClient } from './client.js'; +export { + USAGETAP_GATEWAY_BASE_URL, + USAGETAP_GATEWAY_DEFAULT_MODEL, + USAGETAP_GATEWAY_RESPONSE_HEADER_NAMES, + createUsageTapGatewayOpenAIConfig, + usagetapAdapter, +} from '../providers/usagetap.js'; +export type { + UsageTapGatewayOpenAIConfig, + UsageTapGatewayOpenAIConfigOptions, +} from '../providers/usagetap.js'; +export type { UsageTapGatewayRuntimeOptions } from '../providers/types.js'; export { beginUsageTapCall, defaultUsageTapErrorMapper, endUsageTapCall, withUsageTapCall } from './lifecycle.js'; export { applyUsageTapEntitlements, diff --git a/src/validation/validate.ts b/src/validation/validate.ts index f636d1b..ff9425e 100644 --- a/src/validation/validate.ts +++ b/src/validation/validate.ts @@ -262,7 +262,7 @@ export function validateAsset( const anthropicCaution = ['if', 'then', 'else']; const checks: Array<{ provider: string[]; unsupported: string[]; code: string }> = [ - { provider: ['openai', 'openai-responses', 'openrouter', 'llmasaservice'], unsupported: openAIUnsupported, code: 'POK052' }, + { provider: ['openai', 'openai-responses', 'openrouter', 'llmasaservice', 'usagetap'], unsupported: openAIUnsupported, code: 'POK052' }, { provider: ['gemini', 'google'], unsupported: geminiUnsupported, code: 'POK053' }, { provider: ['anthropic'], unsupported: anthropicCaution, code: 'POK054' }, ]; diff --git a/tests/defaults.test.ts b/tests/defaults.test.ts index 23a2a56..d3d69e2 100644 --- a/tests/defaults.test.ts +++ b/tests/defaults.test.ts @@ -15,6 +15,34 @@ describe('defaults.md inheritance', () => { await rm(tmpDir, { recursive: true, force: true }); }); + it('accepts UsageTap as a folder default and merges its raw and typed options', async () => { + await writeFile(join(tmpDir, 'defaults.md'), `--- +provider: usagetap +provider_options: + usagetap: + feature: defaults-feature +raw: + usagetap: + route: defaults +--- +`); + await writeFile(join(tmpDir, 'hello.md'), `--- +id: hello +provider_options: + usagetap: + conversationId: conversation-1 +--- +# Prompt template +Hello +`); + const { asset } = await loadPromptFile(join(tmpDir, 'hello.md'), { defaultsRoot: tmpDir }); + expect(asset.provider).toBe('usagetap'); + expect(asset.provider_options?.usagetap).toEqual({ + feature: 'defaults-feature', conversationId: 'conversation-1', + }); + expect(asset.raw?.usagetap).toEqual({ route: 'defaults' }); + }); + it('applies folder defaults when a prompt omits metadata and system instructions', async () => { await writeFile(join(tmpDir, 'defaults.md'), `--- metadata: diff --git a/tests/providers.test.ts b/tests/providers.test.ts index 44f6c67..77b3e58 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -11,12 +11,13 @@ import { createLLMAsAServiceOpenAIConfig, llmasaserviceAdapter, } from '../src/providers/llmasaservice.js'; +import { usagetapAdapter } from '../src/providers/usagetap.js'; import { getAdapter } from '../src/providers/index.js'; import { PromptAssetSchema } from '../src/schema/index.js'; import type { ResolvedPromptAsset } from '../src/schema/index.js'; import { createPromptOpsKit } from '../src/index.js'; -const adaptersWithPromptInput = [openaiAdapter, openaiResponsesAdapter, anthropicAdapter, geminiAdapter, openrouterAdapter, llmasaserviceAdapter] as const; +const adaptersWithPromptInput = [openaiAdapter, openaiResponsesAdapter, anthropicAdapter, geminiAdapter, openrouterAdapter, llmasaserviceAdapter, usagetapAdapter] as const; const baseAsset: ResolvedPromptAsset = { id: 'test', diff --git a/tests/usagetap-gateway.test.ts b/tests/usagetap-gateway.test.ts new file mode 100644 index 0000000..50f2873 --- /dev/null +++ b/tests/usagetap-gateway.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { PromptAssetSchema } from '../src/schema/index.js'; +import { getAdapter } from '../src/providers/index.js'; +import { + createUsageTapGatewayOpenAIConfig, + USAGETAP_GATEWAY_BASE_URL, + USAGETAP_GATEWAY_DEFAULT_MODEL, + usagetapAdapter, +} from '../src/providers/usagetap.js'; +import type { ResolvedPromptAsset } from '../src/schema/index.js'; + +const asset: ResolvedPromptAsset = { + id: 'gateway', schema_version: 1, provider: 'usagetap', + sections: { prompt_template: 'Hello' }, +}; + +describe('UsageTap gateway adapter', () => { + it('is registered and has the documented defaults and OpenAI config', () => { + expect(PromptAssetSchema.parse(asset).provider).toBe('usagetap'); + expect(getAdapter('usagetap')).toBe(usagetapAdapter); + expect(USAGETAP_GATEWAY_DEFAULT_MODEL).toBe('usagetap/standard'); + expect(USAGETAP_GATEWAY_BASE_URL).toBe('https://gateway.usagetap.com/v1'); + expect(createUsageTapGatewayOpenAIConfig({ apiKey: 'secret' })).toEqual({ + apiKey: 'secret', baseURL: 'https://gateway.usagetap.com/v1', + }); + }); + + it('requires credentials only when validating/rendering with runtime options', () => { + expect(usagetapAdapter.validate(asset).valid).toBe(true); + expect(usagetapAdapter.validate(asset).warnings).toHaveLength(1); + expect(usagetapAdapter.validate(asset, {}).valid).toBe(false); + expect(() => usagetapAdapter.render(asset, {})).toThrow(/usagetap\.apiKey/); + }); + + it('renders optional metadata, compression, fallbacks, raw overrides, and headers', () => { + const result = usagetapAdapter.render({ + ...asset, + model: 'openai/gpt-5-mini', + fallback_models: ['openai/gpt-5-mini', 'anthropic/claude-sonnet', 'openai/gpt-4.1'], + sampling: { max_output_tokens: 321, temperature: 0.4, top_p: 0.8 }, + response: { stream: true, format: 'json', schema: { type: 'object' } }, + tools: [{ name: 'lookup', input_schema: { type: 'object' } }], + provider_options: { usagetap: { + customer: { customer_id: 'c1', customer_email: 'billing@example.com', customer_user_name: 'Avery' }, + feature: 'prompt-tightener', conversationId: 'conv', conversationTitle: 'Tools', projectId: 'tools', + compress: { mode: 'deterministic', aggressiveness: 0.35, failOpen: true }, + } }, + raw: { usagetap: { feature: 'raw-wins', extension: true } }, + }, { usagetap: { apiKey: 'secret', idempotencyKey: 'request-1' } }); + + expect(result.provider).toBe('usagetap'); + expect(result.headers).toEqual({ Authorization: 'Bearer secret', 'Idempotency-Key': 'request-1' }); + expect(result.body).toMatchObject({ + model: 'openai/gpt-5-mini', max_completion_tokens: 321, temperature: 0.4, top_p: 0.8, + models: ['anthropic/claude-sonnet', 'openai/gpt-4.1'], feature: 'raw-wins', + conversationId: 'conv', conversationTitle: 'Tools', projectId: 'tools', extension: true, + customer: { customer_id: 'c1', customer_email: 'billing@example.com', customer_user_name: 'Avery' }, + compress: { mode: 'deterministic', aggressiveness: 0.35, failOpen: true }, + stream: true, + }); + expect(result.body).not.toHaveProperty('max_tokens'); + expect(result.body).not.toHaveProperty('apiKey'); + expect(result.body).not.toHaveProperty('idempotencyKey'); + expect(result.body).not.toHaveProperty('Idempotency-Key'); + }); + + it('does not require customer attribution and supports runtime raw merging', () => { + const result = usagetapAdapter.render(asset, { + usagetap: { apiKey: 'secret' }, + runtime: { raw: { usagetap: { route: 'runtime' } } }, + }); + expect(result.model).toBe('usagetap/standard'); + expect(result.body).toMatchObject({ model: 'usagetap/standard', route: 'runtime' }); + expect(result.body).not.toHaveProperty('customer'); + }); + + it('renders an inline source through prompt input support', async () => { + const result = await usagetapAdapter.renderPrompt({ source: `---\nid: inline\nprovider: usagetap\n---\n# Prompt template\nHi` }, { + usagetap: { apiKey: 'secret' }, + }); + expect(result.provider).toBe('usagetap'); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 26d47c7..776bd21 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -13,6 +13,7 @@ export default defineConfig([ 'providers/gemini': 'src/providers/gemini.ts', 'providers/openrouter': 'src/providers/openrouter.ts', 'providers/llmasaservice': 'src/providers/llmasaservice.ts', + 'providers/usagetap': 'src/providers/usagetap.ts', }, format: ['esm', 'cjs'], dts: true,