@maxceem/agw 0.1.7 → 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 +47 -12
  2. package/dist/agw.mjs +344 -91
  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,17 +126,44 @@ 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
+ ```
122
144
 
123
- The CLI lives in `cli/` of the
124
- [main repository](https://github.com/maxceem/app-ai-gateway). From the repo root:
145
+ It keeps its state in `.agw-dev/`, separate from the state an installed `agw`
146
+ uses. To start again from nothing:
125
147
 
126
148
  ```sh
127
- pnpm run cli:check # type-check and bundle
128
- pnpm run cli:test # run the CLI tests
149
+ pnpm cli:dev:reset
129
150
  ```
130
151
 
131
- Both are part of `pnpm run verify`. Releasing is two steps in order:
132
- `pnpm run cli:release:github` builds the gateway archive and uploads it to the
133
- GitHub release, then `npm publish` from `cli/` ships the executable that
134
- demands that exact archive. Licensed under Apache-2.0.
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
+ ```
159
+
160
+ Type-check and test:
161
+
162
+ ```sh
163
+ pnpm run cli:check
164
+ pnpm run cli:test
165
+ ```
166
+
167
+ ## License
168
+
169
+ This project is licensed under the [Apache License 2.0](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.7", wrangler: "4.96.0", 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"], release: { url: "https://github.com/maxceem/app-ai-gateway/releases/download/v0.1.7/gateway-0.1.7.tar.gz", sha256: "e9a154b8b0c71e38296995b8ae478f26b563d2eb7fb1111e575d3fde33815b88", manifest: "ac56329e6cc34a01ecb3c8820b671b74bd4fabfb69d2c9353307a5306332a663" } };
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" });
@@ -15553,9 +15553,12 @@ var CliBrowserDetailsResponseSchema = external_exports.object({
15553
15553
  googleEnabled: external_exports.boolean(),
15554
15554
  expiresAt: external_exports.string()
15555
15555
  });
15556
+ var CliHandoffContinuationSchema = external_exports.enum(["cli", "console"]);
15556
15557
  var CliBrowserSubmitResponseSchema = external_exports.object({
15557
15558
  state: external_exports.literal("completed"),
15558
- message: external_exports.string()
15559
+ /** The whole of what the page says once it is approved. */
15560
+ message: external_exports.string(),
15561
+ continueTo: CliHandoffContinuationSchema
15559
15562
  });
15560
15563
  var CliBrowserRegisterResponseSchema = external_exports.object({
15561
15564
  token: external_exports.string().nullable().optional(),
@@ -15748,12 +15751,27 @@ var CliErrorDetailsSchema = external_exports.object({
15748
15751
  providerGatewayId: external_exports.string().optional(),
15749
15752
  revoked: external_exports.boolean().optional(),
15750
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(),
15751
15761
  id: external_exports.string().optional(),
15752
15762
  state: external_exports.string().optional(),
15753
15763
  expiresAt: external_exports.string().optional(),
15754
15764
  url: external_exports.string().optional(),
15755
15765
  /** The tail of a failed subprocess's own output, for failures it explains. */
15756
- 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()
15757
15775
  });
15758
15776
 
15759
15777
  // src/apps.ts
@@ -16220,7 +16238,10 @@ function parse3(argv) {
16220
16238
  if (!match || !types[match[1]])
16221
16239
  fail(
16222
16240
  "unknown_flag",
16223
- `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
16224
16245
  );
16225
16246
  const name = match[1];
16226
16247
  const inline = match[2];
@@ -16932,6 +16953,129 @@ var StateStore = class {
16932
16953
  }
16933
16954
  };
16934
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
+
16935
17079
  // src/apps.ts
16936
17080
  var unlimited = () => ({
16937
17081
  requests: { per_minute: null, per_day: null },
@@ -17171,20 +17315,22 @@ async function appCommand(ctx, command, args, flags) {
17171
17315
  ({ app, resolved, config_error: configError } = updated.data);
17172
17316
  }
17173
17317
  let snippet;
17174
- if (doc2.config.authentication.type === "apple_app_attest") {
17175
- try {
17176
- const example = await appCommand(ctx, "app snippet", [app.id], {});
17177
- if ("snippet" in example) snippet = example.snippet;
17178
- } catch {
17179
- }
17318
+ try {
17319
+ const example = await appCommand(ctx, "app snippet", [app.id], {});
17320
+ if ("snippet" in example) snippet = example.snippet;
17321
+ } catch {
17180
17322
  }
17323
+ if (snippet && key)
17324
+ snippet = `export APP_AI_GATEWAY_KEY="$(cat ${shellQuote(key.storagePath)})"
17325
+
17326
+ ${snippet}`;
17181
17327
  return {
17182
17328
  ...snippet ? { snippet } : {},
17183
17329
  app,
17184
17330
  resolved,
17185
17331
  config_error: configError,
17186
17332
  ...key ? { applicationKey: key } : {},
17187
- 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.`
17188
17334
  };
17189
17335
  } finally {
17190
17336
  await output?.cancel();
@@ -17271,62 +17417,75 @@ async function appCommand(ctx, command, args, flags) {
17271
17417
  };
17272
17418
  }
17273
17419
  if (action === "snippet") {
17274
- if (flags.language && flags.language !== "swift")
17275
- fail("invalid_input", "Only --language swift is supported.");
17276
- 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)
17277
17425
  fail(
17278
17426
  "unsupported_snippet",
17279
- "Swift snippets are for iOS applications.",
17280
- "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"}.`
17281
17429
  );
17282
- let target;
17430
+ const notes = [];
17431
+ let example;
17283
17432
  if (flags.endpoint) {
17284
- if (!doc.config.endpoints?.[flags.endpoint])
17433
+ const endpoint = doc.config.endpoints?.[flags.endpoint];
17434
+ if (!endpoint)
17285
17435
  fail("endpoint_not_found", "Choose an existing named endpoint.");
17286
- 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
+ };
17287
17443
  } else {
17444
+ const routing = {
17445
+ providerMode: doc.config.routing.providers.mode,
17446
+ providers: doc.config.routing.providers.selected
17447
+ };
17288
17448
  const { data: all } = await ctx.call("listProviders", []);
17289
- let providers = all.providers.filter(
17290
- (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))
17291
17451
  );
17292
17452
  const requested = typeof flags.provider === "string" ? flags.provider : void 0;
17293
- if (requested) providers = providers.filter((p2) => p2.slug === requested);
17294
- const p = providers[0];
17295
- if (providers.length !== 1 || !p)
17453
+ if (requested && !reachable.some((p) => p.slug === requested))
17296
17454
  fail(
17297
- "provider_required",
17298
- "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."
17299
17458
  );
17300
- const { data: capabilities } = await ctx.publicCall("getCliCapabilities", []);
17301
- const cap = capabilities.providers.find((c) => c.type === p.type);
17302
- const path = cap?.defaultPath;
17303
- if (!path)
17304
- fail(
17305
- "provider_path_required",
17306
- "This provider has no default snippet path in deployment capabilities.",
17307
- "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."
17308
17474
  );
17309
- target = `provider: ${JSON.stringify(p.slug)},
17310
- providerPath: ${JSON.stringify(path)}`;
17311
- }
17312
- const issuer = doc.config.authentication.end_user.source === "issuer";
17313
- 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";
17314
- const snippet = `// Swift package: https://github.com/maxceem/app-ai-gateway-swift (from: 1.0.0)
17315
- // Enable App Attest and test on a supported physical device.
17316
- import Foundation
17317
- import AppAIGateway
17318
-
17319
- let gateway = AppAIGatewayClient(
17320
- appID: ${JSON.stringify(appId)},
17321
- baseURL: URL(string: ${JSON.stringify(ctx.url)})!,
17322
- authMode: ${authMode}
17323
- )
17324
-
17325
- var request = try await gateway.authorizedRequest(
17326
- ${target}
17327
- )
17328
- // Supply the provider-native JSON body, then send request with URLSession.
17329
- `;
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
+ }
17330
17489
  if (flags.output) {
17331
17490
  const output = await reserveOutput(flags.output);
17332
17491
  try {
@@ -17336,13 +17495,7 @@ var request = try await gateway.authorizedRequest(
17336
17495
  await output.cancel();
17337
17496
  }
17338
17497
  }
17339
- return {
17340
- language: "swift",
17341
- snippet,
17342
- ...issuer ? {
17343
- requires: "Replace yourIdentitySDK.currentIDToken(forceRefresh: forceRefresh) with your configured issuer integration."
17344
- } : {}
17345
- };
17498
+ return { language: ios ? "swift" : "shell", snippet, notes };
17346
17499
  }
17347
17500
  fail("unknown_command", "Unknown command.");
17348
17501
  }
@@ -17882,9 +18035,11 @@ async function openBrowser(url2) {
17882
18035
 
17883
18036
  // src/deployment.ts
17884
18037
  import { spawn as spawn2 } from "node:child_process";
18038
+ import { stripVTControlCharacters } from "node:util";
17885
18039
  import { createRequire as createRequire2 } from "node:module";
17886
18040
  import { randomUUID } from "node:crypto";
17887
18041
  import { cp, writeFile as writeFile2, unlink as unlink2 } from "node:fs/promises";
18042
+ import { tmpdir } from "node:os";
17888
18043
  import { join as join3 } from "node:path";
17889
18044
  import { setTimeout as delay3 } from "node:timers/promises";
17890
18045
 
@@ -18130,9 +18285,11 @@ async function release(requested = VERSION, base = dirname(fileURLToPath(import.
18130
18285
 
18131
18286
  // src/deployment.ts
18132
18287
  var require2 = createRequire2(import.meta.url);
18133
- var OUTPUT_TAIL = 2e3;
18288
+ var OUTPUT_TAIL = 5e3;
18134
18289
  function wranglerFailure(args, code, output, redact = []) {
18135
- 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);
18136
18293
  return new CliError(
18137
18294
  "wrangler_failed",
18138
18295
  `wrangler ${args[0] ?? ""} failed${code === null ? "" : ` (exit ${code})`}.`,
@@ -18141,16 +18298,21 @@ function wranglerFailure(args, code, output, redact = []) {
18141
18298
  tail ? { output: tail } : void 0
18142
18299
  );
18143
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
+ }
18144
18311
  async function runWrangler(args, { cwd, input, interactive = false, redact } = {}) {
18145
18312
  return new Promise((resolve3, reject2) => {
18146
18313
  const child = spawn2(process.execPath, [require2.resolve("wrangler/bin/wrangler.js"), ...args], {
18147
18314
  ...cwd === void 0 ? {} : { cwd },
18148
- env: {
18149
- ...process.env,
18150
- WRANGLER_SEND_METRICS: "false",
18151
- WRANGLER_LOG: "error",
18152
- CI: interactive ? "" : "true"
18153
- },
18315
+ env: wranglerEnvironment({ interactive }),
18154
18316
  stdio: [input ? "pipe" : interactive ? "inherit" : "ignore", "pipe", "pipe"]
18155
18317
  });
18156
18318
  let output = "";
@@ -18181,6 +18343,24 @@ async function runWrangler(args, { cwd, input, interactive = false, redact } = {
18181
18343
  });
18182
18344
  });
18183
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
+ }
18184
18364
  var Cloudflare = class {
18185
18365
  run;
18186
18366
  fetch;
@@ -18189,22 +18369,62 @@ var Cloudflare = class {
18189
18369
  this.run = run;
18190
18370
  this.fetch = fetchImpl;
18191
18371
  }
18192
- 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;
18193
18386
  try {
18194
- 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);
18195
18399
  } catch {
18196
- if (flags["no-input"] || flags["dry-run"])
18197
- fail(
18198
- "cloudflare_auth_required",
18199
- "Cloudflare authentication is required.",
18200
- "Set CLOUDFLARE_API_TOKEN securely, or run setup interactively to authorize Cloudflare.",
18201
- 4
18202
- );
18203
- await this.run(["login"], { interactive: true });
18204
- 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
+ );
18205
18406
  }
18206
- if (!this.auth?.token && this.auth?.type !== "api_key")
18207
- 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
+ );
18208
18428
  }
18209
18429
  authHeaders() {
18210
18430
  const auth = this.auth;
@@ -18240,13 +18460,16 @@ var Cloudflare = class {
18240
18460
  } catch {
18241
18461
  fail("cloudflare_error", "Cloudflare returned an invalid response.");
18242
18462
  }
18243
- if (!response.ok || !data.success)
18463
+ if (!response.ok || !data.success) {
18464
+ const reported2 = apiErrors(data);
18244
18465
  fail(
18245
18466
  "cloudflare_error",
18246
- `Cloudflare rejected the request (HTTP ${response.status}).`,
18467
+ `Cloudflare rejected the request (HTTP ${response.status})${reported2 ? `: ${reported2}` : ""}`,
18247
18468
  "Check Cloudflare permissions and the deployment resource IDs; retry the same command.",
18248
- 3
18469
+ 3,
18470
+ reported2 ? { apiErrors: reported2 } : void 0
18249
18471
  );
18472
+ }
18250
18473
  return data;
18251
18474
  }
18252
18475
  async all(path) {
@@ -18830,6 +19053,27 @@ function reported(value, sent) {
18830
19053
  }
18831
19054
  var RECOVERABLE_RESOURCE_CODES = ["resource_receipt_expired", "resource_key_unavailable"];
18832
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
+ }
18833
19077
  var Transport = class {
18834
19078
  fetch;
18835
19079
  constructor(fetchImpl = fetch) {
@@ -18884,18 +19128,27 @@ var Transport = class {
18884
19128
  const wire = data ?? {};
18885
19129
  const code = typeof wire.error?.code === "string" ? wire.error.code : typeof wire.code === "string" ? wire.code : `http_${response.status}`;
18886
19130
  const details = { status: response.status };
18887
- if (RECOVERABLE_RESOURCE_CODES.includes(code)) {
18888
- const reported2 = wire.error?.data ?? {};
19131
+ const facts = wire.error?.data ?? {};
19132
+ if (RECOVERABLE_RESOURCE_CODES.includes(code))
18889
19133
  for (const field of RECOVERABLE_RESOURCE_FIELDS) {
18890
- const value = reported2[field];
19134
+ const value = facts[field];
18891
19135
  if (typeof value === "string") details[field] = value;
18892
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
+ }
18893
19146
  }
18894
19147
  const explanation = reported(wire.error?.message ?? wire.message, body);
18895
19148
  fail(
18896
19149
  code,
18897
19150
  `Deployment rejected the request (HTTP ${response.status})` + (explanation ? `: ${explanation}` : "."),
18898
- response.status === 401 ? "Run agw account login." : "Review command configuration and account status.",
19151
+ nextActionFor(response.status, code, details),
18899
19152
  3,
18900
19153
  details
18901
19154
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxceem/agw",
3
- "version": "0.1.7",
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.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"