@maxceem/agw 0.1.8 → 0.1.9

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.
Files changed (3) hide show
  1. package/README.md +44 -8
  2. package/dist/agw.mjs +340 -90
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -27,18 +27,26 @@ Add a provider. Paste its API key into the hidden prompt.
27
27
  agw provider add --type openai
28
28
  ```
29
29
 
30
- Add an app. It uses every provider you have added.
30
+ Add an app. It uses every provider you have added. A server app is created with
31
+ an application key, written to a private file; an iOS app authenticates with
32
+ App Attest instead and ships no key.
31
33
 
32
34
  ```sh
35
+ agw app add --type server --name "My backend"
33
36
  agw app add --type ios --team-id ABCDE12345 --bundle-id com.example.app
34
37
  ```
35
38
 
36
- Print the Swift code that calls the gateway from that app.
39
+ Creating an app prints the request it can send. To print it again — as curl for
40
+ a server app, as Swift for an iOS one:
37
41
 
38
42
  ```sh
39
- agw app snippet <app-id> --provider openai
43
+ agw app snippet <app-id>
44
+ agw app snippet <app-id> --provider openai # when several could serve it
40
45
  ```
41
46
 
47
+ The example is written from the app's own providers and models, and uses named
48
+ placeholders such as `PROVIDER_SLUG` for anything not configured yet.
49
+
42
50
  ## Self-host on Cloudflare
43
51
 
44
52
  The package drives Wrangler and installs the gateway release of its own version.
@@ -118,14 +126,42 @@ overwritten. `app validate --file` checks a file without touching the account.
118
126
  Windows), mode 0700. Do not delete it to fix an authentication error; run
119
127
  `agw account login` instead. Output files are never overwritten.
120
128
 
121
- ## Contributing
129
+ ## Local development
130
+
131
+ The source lives in `cli/` of the
132
+ [main repository](https://github.com/maxceem/app-ai-gateway). Clone it and run
133
+ `pnpm install`.
134
+
135
+ From the repository root, `pnpm cli:dev` is `agw` built from your own source
136
+ instead of an installed copy. It rebuilds before every run, so your latest edit
137
+ is always the one that runs.
138
+
139
+ ```sh
140
+ pnpm cli:dev account login
141
+ pnpm cli:dev provider add --type openai
142
+ pnpm cli:dev app list
143
+ ```
144
+
145
+ It keeps its state in `.agw-dev/`, separate from the state an installed `agw`
146
+ uses. To start again from nothing:
147
+
148
+ ```sh
149
+ pnpm cli:dev:reset
150
+ ```
151
+
152
+ Deploying needs the gateway itself built too, once per change to the Worker,
153
+ console or migrations:
154
+
155
+ ```sh
156
+ pnpm --filter @maxceem/agw release:build
157
+ pnpm cli:dev deployment setup --name my-gateway --no-domain
158
+ ```
122
159
 
123
- The CLI lives in `cli/` of the
124
- [main repository](https://github.com/maxceem/app-ai-gateway). From the repo root:
160
+ Type-check and test:
125
161
 
126
162
  ```sh
127
- pnpm run cli:check # type-check and bundle
128
- pnpm run cli:test # run the CLI tests
163
+ pnpm run cli:check
164
+ pnpm run cli:test
129
165
  ```
130
166
 
131
167
  ## License
package/dist/agw.mjs CHANGED
@@ -6,7 +6,7 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // <define:__AGW_MANIFEST__>
9
- var define_AGW_MANIFEST_default = { version: "0.1.8", wrangler: "4.96.0", upgradeFrom: ["0.1.8", "0.1.7", "0.1.6", "0.1.5", "0.1.4", "0.1.3", "0.1.2", "0.1.1", "0.1.0"], release: { url: "https://github.com/maxceem/app-ai-gateway/releases/download/v0.1.8/gateway-0.1.8.tar.gz", sha256: "a4123a9186809038e7613f80d299637075eee47016b7aa309fd82f3a6a978ee9", manifest: "f6b6451ba84b7409c5f1e239e57319aa2e92bb54739bb07f27171d652c68b326" } };
9
+ var define_AGW_MANIFEST_default = { version: "0.1.9", wrangler: "4.96.0", upgradeFrom: ["0.1.9", "0.1.8", "0.1.7", "0.1.6", "0.1.5", "0.1.4", "0.1.3", "0.1.2", "0.1.1", "0.1.0"], release: { url: "https://github.com/maxceem/app-ai-gateway/releases/download/v0.1.9/gateway-0.1.9.tar.gz", sha256: "a3722d70b8345b41d9e57662985141006214a1b02628af6016b4763e0d177287", manifest: "40ef2a1c2a44d1530a45a3a20eb4df4eb2a840af71ee53762432430f8262c142" } };
10
10
 
11
11
  // src/main.ts
12
12
  import { Writable } from "node:stream";
@@ -15018,7 +15018,7 @@ var ErrorResponseSchema = external_exports.object({
15018
15018
  code: external_exports.string(),
15019
15019
  message: external_exports.string(),
15020
15020
  data: external_exports.record(external_exports.string(), external_exports.union([external_exports.string(), external_exports.number(), external_exports.boolean()])).optional().meta({
15021
- description: "Machine-readable facts about this rejection, present only where the code alone is not actionable. A billing_request_quota_exceeded rejection carries periodId, periodStart, periodEnd, limit, used, and resetAt; an app_rate_limited or app_budget_exhausted rejection carries scope, either user or app."
15021
+ description: "Machine-readable facts about this rejection, present only where the code alone is not actionable. A billing_request_quota_exceeded rejection carries periodId, periodStart, periodEnd, limit, used, and resetAt; a billing_plan_limit_reached rejection carries limit and used; an app_rate_limited or app_budget_exhausted rejection carries scope, either user or app; a rate_limited rejection carries scope naming the endpoint policy that refused, plus limit, windowSeconds, retryAfterSeconds and resetAt."
15022
15022
  })
15023
15023
  })
15024
15024
  }).meta({ id: "ErrorResponse" });
@@ -15751,12 +15751,27 @@ var CliErrorDetailsSchema = external_exports.object({
15751
15751
  providerGatewayId: external_exports.string().optional(),
15752
15752
  revoked: external_exports.boolean().optional(),
15753
15753
  storagePath: external_exports.string().optional(),
15754
+ /** Which ceiling refused the request, for the codes that name one. */
15755
+ scope: external_exports.string().optional(),
15756
+ limit: external_exports.number().optional(),
15757
+ used: external_exports.number().optional(),
15758
+ windowSeconds: external_exports.number().optional(),
15759
+ retryAfterSeconds: external_exports.number().optional(),
15760
+ resetAt: external_exports.string().optional(),
15754
15761
  id: external_exports.string().optional(),
15755
15762
  state: external_exports.string().optional(),
15756
15763
  expiresAt: external_exports.string().optional(),
15757
15764
  url: external_exports.string().optional(),
15758
15765
  /** The tail of a failed subprocess's own output, for failures it explains. */
15759
- output: external_exports.string().optional()
15766
+ output: external_exports.string().optional(),
15767
+ /**
15768
+ * What Cloudflare itself said about a refused API call: each error's message
15769
+ * and its code. Carried because the things that refuse a deployment — an
15770
+ * account ceiling, a permission a token lacks — are named only there, and
15771
+ * wrangler reports an API refusal it aggregates as the bare sentence that a
15772
+ * request failed.
15773
+ */
15774
+ apiErrors: external_exports.string().optional()
15760
15775
  });
15761
15776
 
15762
15777
  // src/apps.ts
@@ -16223,7 +16238,10 @@ function parse3(argv) {
16223
16238
  if (!match || !types[match[1]])
16224
16239
  fail(
16225
16240
  "unknown_flag",
16226
- `Unknown flag ${token.split("=")[0]} for ${command}.`
16241
+ `Unknown flag ${token.split("=")[0]} for ${command}.`,
16242
+ // Resources are addressed positionally throughout, so the one mistake
16243
+ // worth answering with more than the help text is naming one as a flag.
16244
+ spec.args && ["id", "app", "app-id"].includes(match?.[1] ?? "") ? `Pass the identifier as an argument: agw ${command}${" <id>".repeat(spec.args)}.` : void 0
16227
16245
  );
16228
16246
  const name = match[1];
16229
16247
  const inline = match[2];
@@ -16935,6 +16953,129 @@ var StateStore = class {
16935
16953
  }
16936
16954
  };
16937
16955
 
16956
+ // ../src/shared/first-request.ts
16957
+ var PROVIDER_PLACEHOLDER = "PROVIDER_SLUG";
16958
+ var MODEL_PLACEHOLDER = "MODEL";
16959
+ var BODY_PLACEHOLDER = "REQUEST_BODY";
16960
+ var EXAMPLE_GAP_NOTES = {
16961
+ provider: `No provider is configured yet, so ${PROVIDER_PLACEHOLDER} stands in for one. Add a provider key, then use its slug.`,
16962
+ model: `No priced model was found for this provider, so ${MODEL_PLACEHOLDER} stands in for one. Use a model the provider serves.`,
16963
+ body: `This app allows no path whose request shape is known here, so ${BODY_PLACEHOLDER} stands in for the body the provider documents.`
16964
+ };
16965
+ function examplePath(type, { gatewayRouted = false, model } = {}) {
16966
+ if (type === "openai") return "v1/responses";
16967
+ if (type === "anthropic") return "v1/messages";
16968
+ if (type === "gemini")
16969
+ return model ? `v1beta/models/${model}:generateContent` : void 0;
16970
+ if (gatewayRouted) return "v1/chat/completions";
16971
+ if (type === "groq") return "openai/v1/chat/completions";
16972
+ if (type === "fireworks") return "inference/v1/chat/completions";
16973
+ if (["deepseek", "perplexity", "bytedance"].includes(type)) return "chat/completions";
16974
+ return "v1/chat/completions";
16975
+ }
16976
+ function bodyFor(path, model) {
16977
+ const messages = [{ role: "user", content: "Say hello." }];
16978
+ if (path.endsWith("responses")) return { model, input: "Say hello." };
16979
+ if (path.endsWith("chat/completions")) return { model, messages };
16980
+ if (path.endsWith("messages")) return { model, max_tokens: 128, messages };
16981
+ if (path.endsWith(":generateContent"))
16982
+ return { contents: [{ parts: [{ text: "Say hello." }] }] };
16983
+ return null;
16984
+ }
16985
+ function modelsFor(provider, policy2, prices) {
16986
+ if (policy2?.allowed_models?.length) return policy2.allowed_models;
16987
+ return [
16988
+ ...Object.keys(provider.pricing ?? {}),
16989
+ ...Object.entries(prices[provider.type] ?? {}).filter(([, price]) => price.output !== void 0).map(([model]) => model)
16990
+ ];
16991
+ }
16992
+ function firstRequest(routing, providers, prices) {
16993
+ let fallback;
16994
+ for (const provider of providers) {
16995
+ if (provider.status !== "active") continue;
16996
+ const policy2 = routing?.providers?.[provider.slug];
16997
+ if (routing?.providerMode === "selected" && !policy2) continue;
16998
+ const models = modelsFor(provider, policy2, prices);
16999
+ const model = models[0] ?? MODEL_PLACEHOLDER;
17000
+ const gaps = models.length ? [] : ["model"];
17001
+ const fallbackPath = examplePath(provider.type, {
17002
+ gatewayRouted: Boolean(provider.providerGatewayId),
17003
+ model
17004
+ });
17005
+ const paths = policy2?.allowed_paths?.length ? policy2.allowed_paths : fallbackPath ? [fallbackPath] : [];
17006
+ for (const entry of paths) {
17007
+ const path = typeof entry === "string" ? entry : entry.path;
17008
+ if (path.includes("*")) continue;
17009
+ const fixed = typeof entry !== "string" ? entry.fixed_model : void 0;
17010
+ const body = bodyFor(path, fixed ?? model);
17011
+ const example = {
17012
+ target: { provider: provider.slug, path },
17013
+ body,
17014
+ anthropic: path.endsWith("messages"),
17015
+ // A fixed model is the app's own, so a catalog that has none of its
17016
+ // own to offer is not a gap on a path that never carries one.
17017
+ gaps: body && fixed ? [] : gaps
17018
+ };
17019
+ if (body) return example;
17020
+ fallback ??= { ...example, gaps: [...example.gaps, "body"] };
17021
+ }
17022
+ }
17023
+ return fallback ?? {
17024
+ target: { provider: PROVIDER_PLACEHOLDER, path: "v1/chat/completions" },
17025
+ body: bodyFor("v1/chat/completions", MODEL_PLACEHOLDER),
17026
+ anthropic: false,
17027
+ gaps: ["provider", "model"]
17028
+ };
17029
+ }
17030
+ function exampleNotes(example) {
17031
+ return example.gaps.map((gap) => EXAMPLE_GAP_NOTES[gap]);
17032
+ }
17033
+ function exampleUrl(baseUrl, appId, target) {
17034
+ const origin2 = baseUrl.replace(/\/+$/, "");
17035
+ const app = `${origin2}/v1/apps/${encodeURIComponent(appId)}`;
17036
+ return "endpoint" in target ? `${app}/endpoints/${encodeURIComponent(target.endpoint)}` : `${app}/proxy/${encodeURIComponent(target.provider)}/${target.path}`;
17037
+ }
17038
+ function shellQuote(value) {
17039
+ return `'${value.replaceAll("'", "'\\''")}'`;
17040
+ }
17041
+ function commented(notes, marker) {
17042
+ return (notes ?? []).map((note) => `${marker} ${note}
17043
+ `).join("");
17044
+ }
17045
+ function curlSnippet(options) {
17046
+ const { baseUrl, appId, example, notes, keyExpression = "$APP_AI_GATEWAY_KEY" } = options;
17047
+ const body = example.body ? JSON.stringify(example.body) : BODY_PLACEHOLDER;
17048
+ return commented(notes, "#") + `curl --fail-with-body ${shellQuote(exampleUrl(baseUrl, appId, example.target))} \\
17049
+ -H "Authorization: Bearer ${keyExpression}" \\
17050
+ -H 'Content-Type: application/json' \\
17051
+ ` + (example.anthropic ? " -H 'anthropic-version: 2023-06-01' \\\n" : "") + ` -d ${shellQuote(body)}
17052
+ `;
17053
+ }
17054
+ function swiftSnippet(options) {
17055
+ const { baseUrl, appId, example, notes, authMode = ".appAttestInstall" } = options;
17056
+ const target = "endpoint" in example.target ? `endpointSlug: ${JSON.stringify(example.target.endpoint)}` : `provider: ${JSON.stringify(example.target.provider)},
17057
+ providerPath: ${JSON.stringify(example.target.path)}`;
17058
+ const body = example.body ? `request.httpBody = Data(${JSON.stringify(JSON.stringify(example.body))}.utf8)` : `// Supply the provider-native JSON body.
17059
+ request.httpBody = Data(${JSON.stringify(BODY_PLACEHOLDER)}.utf8)`;
17060
+ return commented(notes, "//") + `import Foundation
17061
+ import AppAIGateway
17062
+
17063
+ let gateway = AppAIGatewayClient(
17064
+ appID: ${JSON.stringify(appId)},
17065
+ baseURL: URL(string: ${JSON.stringify(baseUrl.replace(/\/+$/, ""))})!,
17066
+ authMode: ${authMode}
17067
+ )
17068
+
17069
+ var request = try await gateway.authorizedRequest(
17070
+ ${target}
17071
+ )
17072
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
17073
+ ${example.anthropic ? 'request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version")\n' : ""}${body}
17074
+ let (data, _) = try await URLSession.shared.data(for: request)
17075
+ print(String(decoding: data, as: UTF8.self))
17076
+ `;
17077
+ }
17078
+
16938
17079
  // src/apps.ts
16939
17080
  var unlimited = () => ({
16940
17081
  requests: { per_minute: null, per_day: null },
@@ -17174,20 +17315,22 @@ async function appCommand(ctx, command, args, flags) {
17174
17315
  ({ app, resolved, config_error: configError } = updated.data);
17175
17316
  }
17176
17317
  let snippet;
17177
- if (doc2.config.authentication.type === "apple_app_attest") {
17178
- try {
17179
- const example = await appCommand(ctx, "app snippet", [app.id], {});
17180
- if ("snippet" in example) snippet = example.snippet;
17181
- } catch {
17182
- }
17318
+ try {
17319
+ const example = await appCommand(ctx, "app snippet", [app.id], {});
17320
+ if ("snippet" in example) snippet = example.snippet;
17321
+ } catch {
17183
17322
  }
17323
+ if (snippet && key)
17324
+ snippet = `export APP_AI_GATEWAY_KEY="$(cat ${shellQuote(key.storagePath)})"
17325
+
17326
+ ${snippet}`;
17184
17327
  return {
17185
17328
  ...snippet ? { snippet } : {},
17186
17329
  app,
17187
17330
  resolved,
17188
17331
  config_error: configError,
17189
17332
  ...key ? { applicationKey: key } : {},
17190
- guidance: doc2.config.authentication.type === "apple_app_attest" ? `Add https://github.com/maxceem/app-ai-gateway-swift from 1.0.0, enable App Attest, and test on a supported physical device. Run agw app snippet ${app.id} --provider <slug> for integration code. Production releases should use production-only App Attest.` : "Store the generated key on your server; never embed it in a mobile application."
17333
+ guidance: doc2.config.authentication.type === "apple_app_attest" ? `Add https://github.com/maxceem/app-ai-gateway-swift from 1.0.0, enable App Attest, and test on a supported physical device. Run agw app snippet ${app.id} --provider <slug> for integration code. Production releases should use production-only App Attest.` : `Store the generated key on your server; never embed it in a mobile application. Run agw app snippet ${app.id} for this request again.`
17191
17334
  };
17192
17335
  } finally {
17193
17336
  await output?.cancel();
@@ -17274,62 +17417,75 @@ async function appCommand(ctx, command, args, flags) {
17274
17417
  };
17275
17418
  }
17276
17419
  if (action === "snippet") {
17277
- if (flags.language && flags.language !== "swift")
17278
- fail("invalid_input", "Only --language swift is supported.");
17279
- if (doc.config.authentication.type !== "apple_app_attest")
17420
+ const ios = doc.config.authentication.type === "apple_app_attest";
17421
+ const language = flags.language ?? (ios ? "swift" : "curl");
17422
+ if (!["swift", "curl"].includes(language))
17423
+ fail("invalid_input", "--language must be swift or curl.");
17424
+ if (language === "swift" !== ios)
17280
17425
  fail(
17281
17426
  "unsupported_snippet",
17282
- "Swift snippets are for iOS applications.",
17283
- "Use your server HTTP client with the stored application key."
17427
+ ios ? "iOS applications authenticate with App Attest, which curl cannot perform." : "Swift snippets are for iOS applications.",
17428
+ `Run agw app snippet ${appId} --language ${ios ? "swift" : "curl"}.`
17284
17429
  );
17285
- let target;
17430
+ const notes = [];
17431
+ let example;
17286
17432
  if (flags.endpoint) {
17287
- if (!doc.config.endpoints?.[flags.endpoint])
17433
+ const endpoint = doc.config.endpoints?.[flags.endpoint];
17434
+ if (!endpoint)
17288
17435
  fail("endpoint_not_found", "Choose an existing named endpoint.");
17289
- target = `endpointSlug: ${JSON.stringify(flags.endpoint)}`;
17436
+ const responses = endpoint.api_style === "responses";
17437
+ example = {
17438
+ target: { endpoint: flags.endpoint },
17439
+ body: responses ? { input: "Say hello." } : null,
17440
+ anthropic: false,
17441
+ gaps: responses ? [] : ["body"]
17442
+ };
17290
17443
  } else {
17444
+ const routing = {
17445
+ providerMode: doc.config.routing.providers.mode,
17446
+ providers: doc.config.routing.providers.selected
17447
+ };
17291
17448
  const { data: all } = await ctx.call("listProviders", []);
17292
- let providers = all.providers.filter(
17293
- (p2) => p2.status === "active" && (doc.config.routing.providers.mode === "all" || Object.hasOwn(doc.config.routing.providers.selected ?? {}, p2.slug))
17449
+ const reachable = all.providers.filter(
17450
+ (p) => p.status === "active" && (routing.providerMode === "all" || Object.hasOwn(routing.providers ?? {}, p.slug))
17294
17451
  );
17295
17452
  const requested = typeof flags.provider === "string" ? flags.provider : void 0;
17296
- if (requested) providers = providers.filter((p2) => p2.slug === requested);
17297
- const p = providers[0];
17298
- if (providers.length !== 1 || !p)
17453
+ if (requested && !reachable.some((p) => p.slug === requested))
17299
17454
  fail(
17300
- "provider_required",
17301
- "Choose one available provider slug using --provider, or use --endpoint."
17455
+ all.providers.some((p) => p.slug === requested) ? "provider_unavailable" : "provider_not_found",
17456
+ all.providers.some((p) => p.slug === requested) ? "That provider is disabled or outside this app's proxy policy." : "No provider has that slug.",
17457
+ "Run agw provider list for the slugs, and agw app show <id> for the policy."
17302
17458
  );
17303
- const { data: capabilities } = await ctx.publicCall("getCliCapabilities", []);
17304
- const cap = capabilities.providers.find((c) => c.type === p.type);
17305
- const path = cap?.defaultPath;
17306
- if (!path)
17307
- fail(
17308
- "provider_path_required",
17309
- "This provider has no default snippet path in deployment capabilities.",
17310
- "Configure and select a named endpoint."
17459
+ const { data: catalog } = await ctx.call("listModelPrices", []);
17460
+ const providers = requested ? reachable.filter((p) => p.slug === requested) : reachable;
17461
+ example = firstRequest(routing, providers, catalog.prices);
17462
+ if (!requested && reachable.length > 1)
17463
+ notes.push(`This app can reach ${reachable.length} providers. Add --provider <slug> for a different one.`);
17464
+ }
17465
+ notes.push(...exampleNotes(example));
17466
+ const clientUrl = ctx.active?.deployment?.apiUrl ?? ctx.url;
17467
+ let snippet;
17468
+ if (ios) {
17469
+ const endUser = doc.config.authentication.type === "apple_app_attest" ? doc.config.authentication.end_user : void 0;
17470
+ const issuer = endUser?.source === "issuer";
17471
+ if (issuer)
17472
+ notes.push(
17473
+ "Replace yourIdentitySDK.currentIDToken(forceRefresh: forceRefresh) with your configured issuer integration."
17311
17474
  );
17312
- target = `provider: ${JSON.stringify(p.slug)},
17313
- providerPath: ${JSON.stringify(path)}`;
17314
- }
17315
- const issuer = doc.config.authentication.end_user.source === "issuer";
17316
- const authMode = issuer ? ".appAttest(issuerTokenProvider: { forceRefresh in\n // Return a fresh signed token from your configured identity SDK.\n try await yourIdentitySDK.currentIDToken(forceRefresh: forceRefresh)\n })" : ".appAttestInstall";
17317
- const snippet = `// Swift package: https://github.com/maxceem/app-ai-gateway-swift (from: 1.0.0)
17318
- // Enable App Attest and test on a supported physical device.
17319
- import Foundation
17320
- import AppAIGateway
17321
-
17322
- let gateway = AppAIGatewayClient(
17323
- appID: ${JSON.stringify(appId)},
17324
- baseURL: URL(string: ${JSON.stringify(ctx.url)})!,
17325
- authMode: ${authMode}
17326
- )
17327
-
17328
- var request = try await gateway.authorizedRequest(
17329
- ${target}
17330
- )
17331
- // Supply the provider-native JSON body, then send request with URLSession.
17332
- `;
17475
+ snippet = swiftSnippet({
17476
+ baseUrl: clientUrl,
17477
+ appId,
17478
+ example,
17479
+ authMode: issuer ? ".appAttest(issuerTokenProvider: { forceRefresh in\n // Return a fresh signed token from your configured identity SDK.\n try await yourIdentitySDK.currentIDToken(forceRefresh: forceRefresh)\n })" : ".appAttestInstall",
17480
+ notes: [
17481
+ "Swift package: https://github.com/maxceem/app-ai-gateway-swift (from: 1.0.0)",
17482
+ "Enable App Attest and test on a supported physical device.",
17483
+ ...notes
17484
+ ]
17485
+ });
17486
+ } else {
17487
+ snippet = curlSnippet({ baseUrl: clientUrl, appId, example, notes });
17488
+ }
17333
17489
  if (flags.output) {
17334
17490
  const output = await reserveOutput(flags.output);
17335
17491
  try {
@@ -17339,13 +17495,7 @@ var request = try await gateway.authorizedRequest(
17339
17495
  await output.cancel();
17340
17496
  }
17341
17497
  }
17342
- return {
17343
- language: "swift",
17344
- snippet,
17345
- ...issuer ? {
17346
- requires: "Replace yourIdentitySDK.currentIDToken(forceRefresh: forceRefresh) with your configured issuer integration."
17347
- } : {}
17348
- };
17498
+ return { language: ios ? "swift" : "shell", snippet, notes };
17349
17499
  }
17350
17500
  fail("unknown_command", "Unknown command.");
17351
17501
  }
@@ -17885,9 +18035,11 @@ async function openBrowser(url2) {
17885
18035
 
17886
18036
  // src/deployment.ts
17887
18037
  import { spawn as spawn2 } from "node:child_process";
18038
+ import { stripVTControlCharacters } from "node:util";
17888
18039
  import { createRequire as createRequire2 } from "node:module";
17889
18040
  import { randomUUID } from "node:crypto";
17890
18041
  import { cp, writeFile as writeFile2, unlink as unlink2 } from "node:fs/promises";
18042
+ import { tmpdir } from "node:os";
17891
18043
  import { join as join3 } from "node:path";
17892
18044
  import { setTimeout as delay3 } from "node:timers/promises";
17893
18045
 
@@ -18133,9 +18285,11 @@ async function release(requested = VERSION, base = dirname(fileURLToPath(import.
18133
18285
 
18134
18286
  // src/deployment.ts
18135
18287
  var require2 = createRequire2(import.meta.url);
18136
- var OUTPUT_TAIL = 2e3;
18288
+ var OUTPUT_TAIL = 5e3;
18137
18289
  function wranglerFailure(args, code, output, redact = []) {
18138
- const tail = redact.filter((value) => value.length >= 8).reduce((text2, value) => text2.split(value).join("[redacted]"), output).trim().slice(-OUTPUT_TAIL);
18290
+ const tail = stripVTControlCharacters(
18291
+ redact.filter((value) => value.length >= 8).reduce((text2, value) => text2.split(value).join("[redacted]"), output)
18292
+ ).trim().slice(-OUTPUT_TAIL);
18139
18293
  return new CliError(
18140
18294
  "wrangler_failed",
18141
18295
  `wrangler ${args[0] ?? ""} failed${code === null ? "" : ` (exit ${code})`}.`,
@@ -18144,16 +18298,21 @@ function wranglerFailure(args, code, output, redact = []) {
18144
18298
  tail ? { output: tail } : void 0
18145
18299
  );
18146
18300
  }
18301
+ function wranglerEnvironment({
18302
+ interactive = false
18303
+ } = {}) {
18304
+ return {
18305
+ ...process.env,
18306
+ WRANGLER_SEND_METRICS: "false",
18307
+ WRANGLER_LOG: "log",
18308
+ CI: interactive ? "" : "true"
18309
+ };
18310
+ }
18147
18311
  async function runWrangler(args, { cwd, input, interactive = false, redact } = {}) {
18148
18312
  return new Promise((resolve3, reject2) => {
18149
18313
  const child = spawn2(process.execPath, [require2.resolve("wrangler/bin/wrangler.js"), ...args], {
18150
18314
  ...cwd === void 0 ? {} : { cwd },
18151
- env: {
18152
- ...process.env,
18153
- WRANGLER_SEND_METRICS: "false",
18154
- WRANGLER_LOG: "error",
18155
- CI: interactive ? "" : "true"
18156
- },
18315
+ env: wranglerEnvironment({ interactive }),
18157
18316
  stdio: [input ? "pipe" : interactive ? "inherit" : "ignore", "pipe", "pipe"]
18158
18317
  });
18159
18318
  let output = "";
@@ -18184,6 +18343,24 @@ async function runWrangler(args, { cwd, input, interactive = false, redact } = {
18184
18343
  });
18185
18344
  });
18186
18345
  }
18346
+ function usable(auth) {
18347
+ return auth.type === "api_key" ? Boolean(auth.key && auth.email) : Boolean(auth.token);
18348
+ }
18349
+ function environmentCredentials() {
18350
+ const env = process.env;
18351
+ const token = env.CLOUDFLARE_API_TOKEN ?? env.CF_API_TOKEN;
18352
+ const key = env.CLOUDFLARE_API_KEY ?? env.CF_API_KEY;
18353
+ const email3 = env.CLOUDFLARE_EMAIL ?? env.CF_EMAIL;
18354
+ return [token, key && email3 ? key : void 0].filter(
18355
+ (value) => Boolean(value)
18356
+ );
18357
+ }
18358
+ var API_ERROR_TAIL = 500;
18359
+ function apiErrors(data) {
18360
+ return (data.errors ?? []).map(
18361
+ (error51) => [error51.message, error51.code === void 0 ? "" : `[code: ${error51.code}]`].filter(Boolean).join(" ")
18362
+ ).filter(Boolean).join("; ").slice(0, API_ERROR_TAIL);
18363
+ }
18187
18364
  var Cloudflare = class {
18188
18365
  run;
18189
18366
  fetch;
@@ -18192,22 +18369,62 @@ var Cloudflare = class {
18192
18369
  this.run = run;
18193
18370
  this.fetch = fetchImpl;
18194
18371
  }
18195
- async authenticate(flags) {
18372
+ /**
18373
+ * The authorization wrangler holds, or undefined when it holds none.
18374
+ *
18375
+ * Only one outcome may be read as "logged out": wrangler exiting non-zero
18376
+ * with nothing in the environment for it to have used. A run that failed
18377
+ * while credentials were set failed at something else, a run that could not
18378
+ * start failed at nothing to do with authorization, and output that will not
18379
+ * parse means this CLI and the wrangler it drives disagree about the
18380
+ * command. Answering any of those with a login would hide the cause behind a
18381
+ * message about something the caller had already done.
18382
+ */
18383
+ async readAuth() {
18384
+ const credentials = environmentCredentials();
18385
+ let reported2;
18196
18386
  try {
18197
- this.auth = JSON.parse(await this.run(["auth", "token", "--json"]));
18387
+ reported2 = await this.run(["auth", "token", "--json"], {
18388
+ cwd: tmpdir(),
18389
+ redact: credentials
18390
+ });
18391
+ } catch (error51) {
18392
+ if (credentials.length > 0 || !(error51 instanceof CliError) || error51.code !== "wrangler_failed")
18393
+ throw error51;
18394
+ return void 0;
18395
+ }
18396
+ let auth;
18397
+ try {
18398
+ auth = JSON.parse(reported2);
18198
18399
  } catch {
18199
- if (flags["no-input"] || flags["dry-run"])
18200
- fail(
18201
- "cloudflare_auth_required",
18202
- "Cloudflare authentication is required.",
18203
- "Set CLOUDFLARE_API_TOKEN securely, or run setup interactively to authorize Cloudflare.",
18204
- 4
18205
- );
18206
- await this.run(["login"], { interactive: true });
18207
- this.auth = JSON.parse(await this.run(["auth", "token", "--json"]));
18400
+ fail(
18401
+ "cloudflare_auth_unreadable",
18402
+ "Wrangler did not report the Cloudflare authorization it holds.",
18403
+ "Reinstall the CLI so the wrangler it bundles is the one it expects, then retry.",
18404
+ 3
18405
+ );
18208
18406
  }
18209
- if (!this.auth?.token && this.auth?.type !== "api_key")
18210
- fail("cloudflare_auth_required", "No Cloudflare authorization is available.");
18407
+ return usable(auth) ? auth : void 0;
18408
+ }
18409
+ async authenticate(flags) {
18410
+ this.auth = await this.readAuth();
18411
+ if (this.auth) return;
18412
+ if (flags["no-input"] || flags["dry-run"])
18413
+ fail(
18414
+ "cloudflare_auth_required",
18415
+ "Cloudflare authentication is required.",
18416
+ "Set CLOUDFLARE_API_TOKEN securely, or run setup interactively to authorize Cloudflare.",
18417
+ 4
18418
+ );
18419
+ await this.run(["login"], { interactive: true, cwd: tmpdir() });
18420
+ this.auth = await this.readAuth();
18421
+ if (!this.auth)
18422
+ fail(
18423
+ "cloudflare_auth_required",
18424
+ "Cloudflare authorization was not completed.",
18425
+ "Read the wrangler output above, complete the browser login, then retry the same command.",
18426
+ 4
18427
+ );
18211
18428
  }
18212
18429
  authHeaders() {
18213
18430
  const auth = this.auth;
@@ -18243,13 +18460,16 @@ var Cloudflare = class {
18243
18460
  } catch {
18244
18461
  fail("cloudflare_error", "Cloudflare returned an invalid response.");
18245
18462
  }
18246
- if (!response.ok || !data.success)
18463
+ if (!response.ok || !data.success) {
18464
+ const reported2 = apiErrors(data);
18247
18465
  fail(
18248
18466
  "cloudflare_error",
18249
- `Cloudflare rejected the request (HTTP ${response.status}).`,
18467
+ `Cloudflare rejected the request (HTTP ${response.status})${reported2 ? `: ${reported2}` : ""}`,
18250
18468
  "Check Cloudflare permissions and the deployment resource IDs; retry the same command.",
18251
- 3
18469
+ 3,
18470
+ reported2 ? { apiErrors: reported2 } : void 0
18252
18471
  );
18472
+ }
18253
18473
  return data;
18254
18474
  }
18255
18475
  async all(path) {
@@ -18833,6 +19053,27 @@ function reported(value, sent) {
18833
19053
  }
18834
19054
  var RECOVERABLE_RESOURCE_CODES = ["resource_receipt_expired", "resource_key_unavailable"];
18835
19055
  var RECOVERABLE_RESOURCE_FIELDS = ["appId", "keyId", "providerId", "providerGatewayId"];
19056
+ var LIMIT_CODES = [
19057
+ "rate_limited",
19058
+ "app_rate_limited",
19059
+ "app_budget_exhausted",
19060
+ "billing_request_quota_exceeded",
19061
+ "billing_plan_limit_reached"
19062
+ ];
19063
+ var LIMIT_TEXT_FIELDS = ["scope", "resetAt"];
19064
+ var LIMIT_NUMBER_FIELDS = ["limit", "used", "windowSeconds", "retryAfterSeconds"];
19065
+ function nextActionFor(status, code, details) {
19066
+ if (status === 401) return "Run agw account login.";
19067
+ if (code === "billing_plan_limit_reached")
19068
+ return "Delete one you no longer need, or move to a plan whose limits are higher.";
19069
+ if (status === 429) {
19070
+ const seconds = details.retryAfterSeconds;
19071
+ if (seconds === void 0)
19072
+ return "Wait for the limit named in the message to reset, then run the same command again.";
19073
+ return seconds >= 3600 && details.resetAt !== void 0 ? `Wait until ${details.resetAt}, then run the same command again.` : `Wait ${seconds} seconds, then run the same command again.`;
19074
+ }
19075
+ return "Review command configuration and account status.";
19076
+ }
18836
19077
  var Transport = class {
18837
19078
  fetch;
18838
19079
  constructor(fetchImpl = fetch) {
@@ -18887,18 +19128,27 @@ var Transport = class {
18887
19128
  const wire = data ?? {};
18888
19129
  const code = typeof wire.error?.code === "string" ? wire.error.code : typeof wire.code === "string" ? wire.code : `http_${response.status}`;
18889
19130
  const details = { status: response.status };
18890
- if (RECOVERABLE_RESOURCE_CODES.includes(code)) {
18891
- const reported2 = wire.error?.data ?? {};
19131
+ const facts = wire.error?.data ?? {};
19132
+ if (RECOVERABLE_RESOURCE_CODES.includes(code))
18892
19133
  for (const field of RECOVERABLE_RESOURCE_FIELDS) {
18893
- const value = reported2[field];
19134
+ const value = facts[field];
18894
19135
  if (typeof value === "string") details[field] = value;
18895
19136
  }
19137
+ if (LIMIT_CODES.includes(code)) {
19138
+ for (const field of LIMIT_TEXT_FIELDS) {
19139
+ const value = facts[field];
19140
+ if (typeof value === "string") details[field] = value;
19141
+ }
19142
+ for (const field of LIMIT_NUMBER_FIELDS) {
19143
+ const value = facts[field];
19144
+ if (typeof value === "number") details[field] = value;
19145
+ }
18896
19146
  }
18897
19147
  const explanation = reported(wire.error?.message ?? wire.message, body);
18898
19148
  fail(
18899
19149
  code,
18900
19150
  `Deployment rejected the request (HTTP ${response.status})` + (explanation ? `: ${explanation}` : "."),
18901
- response.status === 401 ? "Run agw account login." : "Review command configuration and account status.",
19151
+ nextActionFor(response.status, code, details),
18902
19152
  3,
18903
19153
  details
18904
19154
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxceem/agw",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "Manage App AI Gateway from a terminal or coding agent: providers, iOS and server apps, usage, and self-hosted deployment on Cloudflare",
6
6
  "keywords": [
@@ -25,7 +25,7 @@
25
25
  "directory": "cli"
26
26
  },
27
27
  "bugs": "https://github.com/maxceem/app-ai-gateway/issues",
28
- "upgradeFrom": ["0.1.7", "0.1.6", "0.1.5", "0.1.4", "0.1.3", "0.1.2", "0.1.1", "0.1.0"],
28
+ "upgradeFrom": ["0.1.8", "0.1.7", "0.1.6", "0.1.5", "0.1.4", "0.1.3", "0.1.2", "0.1.1", "0.1.0"],
29
29
  "license": "Apache-2.0",
30
30
  "bin": {
31
31
  "agw": "dist/agw.mjs"