Skip to content

AI-3201: fork takes tool_timeout_ms, 30 s default documented - #2004

Merged
Loschcode merged 8 commits into
acceleratefrom
laurentschaffner/ai-3201-image-generation-latency
Nov 13, 2026
Merged

Loschcode merged 8 commits into
acceleratefrom
laurentschaffner/ai-3201-image-generation-latency

Conversation

@Loschcode

@Loschcode Loschcode commented Nov 13, 2026 •

Copy link
Copy Markdown

Async delivery, the fix Laurent chose from the production data below, shipped in #2013 (merged into accelerate): generate_image answers at once and the picture is drawn in the background and posted when ready. This PR is the rest of AI-3201, merged with accelerate after #2013, so it carries no image job code of its own.

Why

One picture took 60 to 140 s, past the 30 s caller-tool timeout, so a customer's own image tool failed with "the image generator timed out" until they set tool_timeout_ms: 180000 (AI-3201, piece 3 of AI-3160). The docs said tool_timeout_ms "Zero is the default" without saying what the default is, and fork() could not take it.

This PR ships the timeout half. The latency half is a finding, not a change, because choosing the fix needs production data this work could not get (below).

How it works

tool result wait (caller or worker tool)
 ├─ the tool's own timeout_ms           -> that (up to 30 min)
 ├─ else the session's tool_timeout_ms  -> that
 └─ else                                -> 30 s (defaultToolTimeout)

fork(tool_timeout_ms?)
 ├─ given                               -> it wins
 ├─ running parent                      -> parent's, also when the fork names a config (new)
 └─ parent ended                        -> 30 s (its row keeps none)
Change Result Test
tool_timeout_ms doc on CreateSessionRequest Says 30 s, and that a tool's own timeout_ms replaces it spec text
tool_timeout_ms on ForkSessionRequest A fork can be given one TestAForkOfAnEndedSessionTakesTheToolTimeoutItIsGiven, TestAForksToolTimeoutWinsOverItsRunningParents
forkSpec carries the timeout across a named config It was silently reset to 30 s TestAForkNamingAConfigKeepsItsRunningParentsToolTimeout
Go ForkOptions.ToolTimeout; JS fork({ tool_timeout_ms }) SDKs send it TestAForksToolTimeoutGoesOverInMilliseconds; resource.test.ts "renames, voices, stops and forks..."
Regenerated Python, Swift, Kotlin, Dart, JS, Go, .NET, PHP, Ruby, Rust models No drift generator checks

A slow caller tool already had its own way out: SessionTool.timeout_ms (schemas.go:1143, JS timeoutMs) replaces the session's for that tool alone (TestAToolsOwnTimeoutReplacesTheSessions, session_test.go:1640). The built-in generate_image (shipped with #2013) is not bound by either: it runs in the background with its own 240 s.

Where the 60 to 140 s can go (code reading, no production numbers)

Stage What the code does Evidence
Router queue One router process holds at most 2 FAL jobs at once, across every customer and model. A third request waits for a slot before it is submitted, bounded only by the 240 s deadline imagegen/fal/fal.go:47, :185-190
Provider image-fast is only fal/alibaba/qwen-image-3/text-to-image. FAL is submit-then-poll, every 3 s, the first poll 3 s after submit routing/router.yaml:3469-3497, fal.go:43, :217-249
Failover None for image-fast (one candidate); a job FAL accepted is never retried elsewhere imagerouter/router.go failover rule
Bytes Returned inline (sync_mode), decoded once to check, not re-encoded; no download from a provider URL fal.go:263, :310-313, imagegen.go Verify
Upload The customer's workaround uploaded to Chat itself; nothing in the router to measure

What is recorded cannot split these. A request row has latency_ms only (routing/call.go:113), which holds the slot wait, FAL's queue, the drawing and the result fetch together; duration_ms is not set for images. router_first_output_seconds buckets stop at 60 s (routing/traffic.go:43), so every 60 to 140 s picture lands in +Inf. Nothing logs or spans FAL's queue time.

No local timing was taken: no FAL key is configured on this machine, and the vendor suite (imagegen/fal/fal_integration_test.go) only checks a picture finishes within 2 minutes.

A parent that ended in the router process that held it still has its spec there, so a fork of it keeps its timeout; the field's "30 seconds" is for a parent known only from its row. The description does not say so, to avoid another round of regenerating ten clients for one clause.

Production data (read-only, Prometheus on the prod clusters)

router_first_output_seconds observes each usage row's latency_ms / 1000 (routing/traffic.go:149-150), so its per-pod _sum and _count give each image request's latency_ms. Prometheus on us-east4, us-west1 and europe-west4 (accelerate-prod) and us-east1 (staging, benchmark) keeps data from 2026-10-29; Postgres was not queried (no client in the router image, and starting a pod in prod was out of bounds).

Value
Image requests since 2026-10-29, all regions 4, all in us-east4, all fal/alibaba/qwen-image-3/text-to-image (the image-fast model), all outcome=ok. None in us-west1, europe-west4, staging or benchmark
When 2026-11-10, 11:23 to 11:33 UTC: the reported incident
latency_ms each 72.3 s, 129.4 s, 60.2 s, 132.3 s (n=4: median 100.9 s, max 132.3 s; p90/p99 mean nothing at this count)
Router pods (count(up{job="accelerate-router"}), 10-min samples) 2 throughout the incident; 2 in 2,134 of 2,161 samples over 15 days, up to 6 only during deploys

Each request's completion is pinned to the ~30 s scrape interval:

pod 7fzz7  A  ~11:23:10 -> ~11:24:30   72.3 s   alone
pod d47rs  B  ~11:26:45 -> ~11:29:00  129.4 s   overlaps C by at most ~45 s
pod d47rs  C  ~11:28:40 -> ~11:29:40   60.2 s
pod 7fzz7  D  ~11:30:20 -> ~11:32:30  132.3 s   alone (starts after C ended)
  • The router's 2-job cap never held a request back. No pod ever had a third job, so nothing waited for a slot. Account-wide, at most 2 jobs ran at once.
  • FAL's account limit is not the cause either, as far as can be seen. FAL documents that every account starts at 2 concurrent running requests and scales to 40 with spend, that IN_QUEUE requests wait instead of failing, and that the actual limit is on the dashboard's Concurrency page (concurrency limits). Our account's figure is only there. Even at 2, A and D ran with nothing else in flight and still took 72 s and 132 s.
  • So the time is FAL's: its queue or cold start plus the drawing, plus at most 3 s of polling granularity. Nothing recorded splits FAL's IN_QUEUE from IN_PROGRESS (the poll reads only status, fal.go:230-232).

Recommendation. Do not raise the cap: it never bound. No faster route exists to switch to: image-fast has one model, and image-quality (Gemini) is configured as the slower, dearer one and has no production requests to measure. What removes the failure is not making anyone wait on a tool timeout: AI-3200's built-in generate_image (240 s) and a caller tool's own timeout_ms cover today's 60 to 132 s, and async "post when ready" delivery is the durable fix if pictures are to stay this slow. Before choosing a faster model, record FAL's queue time against its drawing time per job, so the next incident says which of the two it was. No code change is made for this here, since the data rules the cap out rather than pointing at a one-line fix.

Not done here: open decisions

  • Latency fix. The production data above rules out the router's cap. Choosing between async delivery and a different model is a product call; FAL's actual account limit is on its dashboard only.
  • tool_timeout_ms on resume. sessions.resume() sends nothing to the router: it fetches the row and watches. A conversation reopened in the process that held it keeps its spec, timeout included. One reopened from its row (reopenedFromRow) gets neither the timeout nor the caller's declared tools back, since the row keeps neither. Carrying it needs either a session-row column (a migration) or a field on the request that reopens it (respond). That is an API design choice, so it is left open.
  • Async "post when ready" delivery shipped in AI-3201: draw pictures in the background, post when ready #2013, the fix chosen from the data above.

Validation

CI: integration (internal/api) fails TestMemorySuite at memory_recall_test.go:218, as accelerate itself does. accelerate's last two runs (43444872895, 43440533515) fail it and integration (internal/memory/mem0) the same way; the mem0 job's log shows the shared mem0 account out of its monthly retrieval quota (50,000 used of 50,000, resets 2026-11-13 21:24 UTC). This PR touches neither. The earlier TestAdmissionSuite failure passed on rerun.

  • go test -tags integration ./internal/api -run 'TestSessionConnectorsSuite/...|TestSessionToolsSuite': the three new fork tests and the existing fork and tool suites pass. With both forkSpec lines removed, all three new tests fail. Wider run: go test ./internal/api ./internal/session ok; -tags integration ./internal/api -run 'TestSessionConnectorsSuite|TestSessionCreateSuite|TestSessionToolsSuite|TestDocumentsSuite|TestAgentTeamsSuite|TestSessionVerbsSuite' ok; sdks/swift/generate.py --check up to date.
  • cd sdks/go && go test ./...: ok. cd sdks/js && npm test (Node 22): 600 passed, 0 failed. npm run types -- --check: in step.
  • Kotlin generate.py (Docker) and Dart tool/generate.dart (in a dart:stable container) regenerated; the diffs add the fork field and the new description only.
  • .NET, PHP, Ruby and Rust models regenerated in Docker as their sdk skills say (the first CI run failed their in-step checks before that). Swift and Kotlin hand-written callers build ForkSessionRequest with named arguments, and the new parameter defaults to nil/null.
  • dev.py check was not run: the worktree has no synced Python env. Only generated Python models changed.

🤖 Generated with Claude Code

Laurent Schaffner and others added 2 commits November 13, 2026 12:12
…efault

ForkSessionRequest gains tool_timeout_ms. A fork keeps a running
parent's timeout, also when it names another config, and the field
wins over both. tool_timeout_ms on createSession now says its default
is 30 seconds and that a tool's own timeout_ms replaces it. Clients
regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Go gains ForkOptions.ToolTimeout; JavaScript's fork() sends the
generated tool_timeout_ms. Both document the router's 30 s default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Loschcode

Copy link
Copy Markdown
Author

/jeffery review

@getstream-jeffery

Copy link
Copy Markdown

Thanks for asking Jeffery for review. I'll stay on this PR until I can approve it.

How it works

  • Findings come as inline comments, each starting with [Must Fix], [Should Fix], nit: or consider:.
  • Push fixes whenever you're ready. Once the branch has been quiet for 10 minutes I review the new head and resolve the threads it fixes. To skip the wait, comment /jeffery review.
  • If you disagree or have a question, reply in the thread. I'll answer there, and resolve it if you're right.
  • I approve once every must-fix and should-fix is closed and nobody has requested changes. CI isn't part of that; branch protection still holds the merge until it's green. From round 3 on, open should-fix findings stop blocking.
  • I don't approve release/* branches or changes to protected paths, and I'll say so when that applies.
  • If a must-fix is still open after round 8, I stop and hand the PR to a human. Draft PRs wait until they're marked ready.

Commands (each as a whole comment)

Command What it does
/jeffery review Review the current head now
/jeffery rereview Start over at round 1 on the current head and withdraw my approval. This redoes the whole review and costs as much as the first one, so use it only when you don't trust the earlier rounds. If nothing changed since my last round I say so instead; add force to redo it anyway
/jeffery pause / /jeffery resume Stop or restart automatic reviews on this PR
/jeffery status What I last reviewed, and why I have or haven't approved
/jeffery help This list

/review-agent or a mention of me works in place of /jeffery.

@getstream-jeffery getstream-jeffery Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 1 on 425f5337048e · 🆕 2 new

Comment thread acceleration/internal/api/sessions.go Resolved
Comment thread sdks/go/client/sessions.go Resolved
getstream-jeffery[bot]
getstream-jeffery Bot previously approved these changes Nov 13, 2026

@getstream-jeffery getstream-jeffery Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved 425f5337048e after 1 round

Left open, your call:

Not covered: Kotlin CreateSessionRequest.kt and swift generated.json hunks were not opened; compare their descriptions with openapi.yaml line 4302; Overflow of time.Duration for a huge tool_timeout_ms (about 9.2e12 ms and above) at session/manager.go:703, and whether huma or JSON decoding caps the value first; this already applies to createSession.

Resolves the Swift client's generated.json by regenerating it from the
merged spec. Every other client regenerated without a change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@getstream-jeffery
getstream-jeffery Bot dismissed their stale review November 13, 2026 16:32

Withdrawing approval until f376644 is reviewed.

The Go client sent a ToolTimeout under a millisecond as zero, which the
router reads as 30 seconds. The fork's tool_timeout_ms doc now says zero
is 30 seconds; clients regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Loschcode

Copy link
Copy Markdown
Author

/jeffery review

@getstream-jeffery getstream-jeffery Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2 on 5defd05a5612 · 🆕 1 new · ✅ 2 fixed

Comment thread .claude/skills/sdk/SKILL.md Resolved
getstream-jeffery[bot]
getstream-jeffery Bot previously approved these changes Nov 13, 2026

@getstream-jeffery getstream-jeffery Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved 5defd05a5612 after 2 rounds · 2 fixed

Left open, your call:

Not covered: Swift generated.json spec_sha256/client_sha256 recomputation against head openapi.yaml (python3 sdks/swift/generate.py); Merged-in Twilio/Telnyx router changes from the accelerate merge commit f376644 were not reviewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Loschcode

Copy link
Copy Markdown
Author

/jeffery review

@getstream-jeffery
getstream-jeffery Bot dismissed their stale review November 13, 2026 16:48

Withdrawing approval until 274e8af is reviewed.

@getstream-jeffery getstream-jeffery Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3 on 274e8af62170 · 🆕 1 new · ✅ 1 fixed

Comment thread .claude/skills/sdk/SKILL.md Resolved
getstream-jeffery[bot]
getstream-jeffery Bot previously approved these changes Nov 13, 2026

@getstream-jeffery getstream-jeffery Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved 274e8af62170 after 3 rounds · 3 fixed

Left open, your call:

Not covered: Per-SDK status claims in the rest of the SKILL.md paragraph (JavaScript, Python, Swift, .NET, Ruby, Rust, PHP, Kotlin, Dart) were unchanged this round and not re-checked; grepping each SDK's fork model for tool_timeout_ms would settle them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Loschcode

Copy link
Copy Markdown
Author

/jeffery review

@getstream-jeffery
getstream-jeffery Bot dismissed their stale review November 13, 2026 16:54

Withdrawing approval until 7dfbfb0 is reviewed.

getstream-jeffery[bot]
getstream-jeffery Bot previously approved these changes Nov 13, 2026

@getstream-jeffery getstream-jeffery Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved 7dfbfb018f5a after 4 rounds · 4 fixed

Not covered: Claims about the JavaScript, Python, Swift, .NET, Ruby, Rust, PHP, Kotlin and Dart fork clients in the same SKILL.md paragraph were not checked; grep tool_timeout_ms under /work/sdks/* for each fork call.

Loschcode added a commit that referenced this pull request Nov 13, 2026
## Why

Production showed a picture takes 60 to 132 s at FAL whatever the router
does: 4 `image-fast` requests on 2026-11-10, two of them run with
nothing else in flight (data in #2004). Waiting for that inside a turn
is what made the customer's tool time out. Laurent chose async delivery:
`generate_image` answers at once, the picture is drawn in the background
and posted into the conversation when ready (AI-3201).

Stacked on #2002 (base
`laurentschaffner/ai-3200-image-generation-let-an-agent-draw`).

## Merge and deploy

- **#2002 and this merge and deploy together. #2002 must never deploy
alone**: on its own it runs every picture inside the turn, with the
6-minute message-hook wait this PR reverts.
- Merge order: #2002, this, #2004, #2003. Regenerate the clients after
each merge: #2002, this and #2004 all change `openapi.yaml` and every
generated client.
- Migrations are strict (`goose.Up`): `20261229090000` (#2002) and
`20261230090000` (this) must be newer than anything on `accelerate` when
they merge. Checked 2026-11-13: `accelerate`'s newest is
`20261226120000`, and no open PR adds a newer one. Check again right
before marking ready.
- `20261230090000` was reshaped in place while this PR was a draft
(last: the `released` column). No shared database ran an earlier draft
(read-only checks, 2026-11-13): every prod router in us-east4, us-west1
and europe-west4 logged `goose: no migrations to run. current version:
20261226120000` at start; the highest version goose logged in the last
30 days is `20261226120000` for `accelerate-staging` (still logging
today) and `20261202135000` for `accelerate-benchmark`, and no log line
in either names `20261229090000`, `20261230090000`, `image_jobs` or
`agent_config_image`; CI's integration databases are per-job
`postgres:16-alpine` service containers. A developer's local database
that ran a draft needs recreating.

## How it works

```
turn: generate_image(prompt, aspect_ratio in 1:1 4:3 3:4 16:9 9:16)
  agent.generateImage -> session drawer -> imagejobs.Queue
    INSERT image_jobs, unique request_key = digest(session, turn, call,
    prompt, shape): a call run again in its turn queues nothing
  <- "being drawn, posted here when ready"     turn ends, model replies

worker (every router; claims SKIP LOCKED, lease 5 min, attempts + 1)
  draw slots: 2 (FAL's per process)            post slots: 16
  undecided job                                 decided job (claimed without bytes)
  ├─ draws >= 2 -> decide failure               ├─ picture kept? else read and upload
  ├─ count the draw, Generate (240 s, one       │    transient (5xx, 408, 429, already
  │   usage id per job, money held 240 s)       │      written) -> back off ~2.5 h
  │   ├─ never left FAL's slot wait             │    refused for good -> decide "could
  │   │   (ErrNotStarted) -> give back,         │      not be posted here"
  │   │   uncounted, for up to 30 min           ├─ post decided message (id: job /
  │   ├─ sent and failed -> decide why          │    job-failed); transient -> back off
  │   └─ drawn (even as Close comes) -> keep    │    refused -> give up
  └─ deliver ─────────────────────────────────> └─ done (picture) or failed (why not)
every write after the claim: WHERE id, state queued, attempts = this claim
```

A message is decided and kept on the row before it is posted, so a post
tried again posts the same message, and a person shown a failure never
later gets a picture drawn and billed behind it.

## Mechanism: Postgres, not asynq

`asynq` is in go.mod, used only for knowledge ingest. Every
delivery-style job here (`chat_inbound`, scheduled tasks, event
forwarding) is a Postgres table claimed with a lease, and this follows
that. Prod Redis is unreplicated and treated as optional (chart README),
and a job has to keep durable state anyway: who to post to, what to
bill, the picture once drawn. No new library.

## Restart story

| Event | What happens | Test |
| --- | --- | --- |
| Deploy with `shutdown.drain` | Stop signal stops claims; the drain
counts drawing and posting jobs and waits for them |
`TestTheDrainWaitsForAPictureBeingDrawn` (cmd/router, runs
`stopServing`) |
| Stopped without a drain (Close) | Job given back at once, its cut draw
uncounted; a kept picture is never redrawn |
`TestARouterThatStopsMidDrawGivesThePictureBackAtOnce` |
| Killed mid-draw | Next router claims it after the 5-min lease and
draws again, at most 2 draws |
`TestARouterKilledMidDrawLeavesThePictureToTheNextOnceItsLeaseRunsOut`,
`TestAJobDrawnAsOftenAsItMayBePostsItsFailure` |
| Killed after posting, before recording | Next claim posts the decided
message under the same id: found written |
`TestAPostWhoseAnswerWasLostIsNotPostedTwice`,
`TestAFailurePostedButNotRecordedIsNotFollowedByAPaidDraw` |
| Stream Chat down | Upload or post backs off over about 2.5 h; draw
claims do not count against it |
`TestAnUploadStreamChatFailedIsTriedAgainWithoutDrawing`,
`TestAFailureWhosePostFailedIsPostedLater`,
`TestAPostIsTriedAgainOverHoursBeforeItIsGivenUp` |
| Stream Chat refuses for good (4xx) | Upload: post that the picture
could not be posted. Post: record failed |
`TestAnUploadStreamChatRefusedForGoodPostsThatItCouldNotBePosted`,
`TestAPostThatNeverGetsThroughIsGivenUpOnceItsWaitsRanOut` |
| 4 pictures in one reply | Drawn two at a time; a draw that waited out
its deadline for a slot is given back uncounted |
`TestFourPicturesAtOnceAreAllDrawnTwoAtATime`,
`TestADrawNoProviderTookInTimeIsTriedAgainUncounted` |
| A job waiting minutes for a FAL slot | Each give-back counts in
`released`, not against the 20-claim cap (`attempts - released`) |
`TestADrawGivenBackMoreTimesThanTheClaimCapIsStillDrawn` |
| Two routers | Each picture drawn and posted once |
`TestTwoRoutersDrawEachPictureOnce`,
`TestASlowClaimCannotFinishAnImageJobAnotherClaimTook` |

**Billing: the customer is billed once, we may pay FAL twice.** Every
draw of a job carries one usage id, `routing.UsageID("image_job",
job.ID)`, into `routing.Meter.UsageID`. Usage rows are unique on
`usage_id` (`requests_usage_id_idx`, inserted `ON CONFLICT (usage_id) DO
NOTHING`, `store/store.go:192`, `:312`), and a reservation made again
under an id already settled is only checked, not held twice
(`policy/holds.go:304-320`). So in the one window where a picture is
drawn again (a router killed in the milliseconds between the draw
returning and the picture being kept, or Postgres unreachable for the
whole 30 s the worker tries to keep it) the second draw settles on the
first one's row: the customer is billed once, while Stream may pay FAL
for both draws. Every other path keeps the picture or was never billed.
`TestAGenerationDrawnTwiceUnderOneUsageIDIsBilledOnce` (imagerouter),
`TestADrawIsBilledUnderTheSessionThatAskedWhenItIsDrawn` (the row
carries the job's usage id).

Requests to `POST /v1/image/generations` share FAL's two slots per
process with the worker. A job that waits out its deadline for a slot
(`imagegen.ErrNotStarted`, set only by that wait) is given back, not
billed and not counted, and tried again for up to 30 minutes, then posts
that it took too long. A draw that was sent and then timed out (Gemini's
120 s call, a FAL submit or poll after FAL took the job) is counted and
posts that it took too long: the vendor may have drawn and billed it.
While a job waits for a slot it holds its money reservation, as an
endpoint request does; with at most two draws per router, at most two
such holds wait.

## What stays and what goes from #2002

| | Decision | Why |
| --- | --- | --- |
| 240 s deadline in `imagerouter.Generate`, money held 240 s | Stays |
The background draw still needs a bound, and a hold that outlasts it |
| Message-hook wait of 2 min + 240 s, lease renewal | Goes (revert of
7772caf) | The turn no longer waits for the picture |
| `chatinbound.Lease` 9 min | Back to 5 min | Same; 9 min only slowed
crash redelivery for every queued message |
| `generate_image` attaching files to the reply | Goes | The picture is
its own message |

## Changes

| Change | Test |
| --- | --- |
| `image_jobs` (`20261230090000`), store
claim/keep/decide/save/retry/release/finish/prune | store:
`TestAnImageJobUnderItsLeaseIsNotClaimedAgain`,
`TestAnImageJobWithItsMessageDecidedIsClaimedOnlyToPost`,
`TestPostedImageJobsAreDeletedAfterADayAndFailedOnesAfter30Days` |
| `internal/imagejobs` worker | the restart table, plus
`TestAPictureIsPostedIntoTheConversationOnceDrawn` (read back in
history), `TestAPictureTheProviderFailedPostsWhyAndCostsNothing` (row:
not a success, cost 0), `TestAPictureTheSafetyFilterRefusedSaysSo`,
`TestACallRunAgainQueuesNoSecondPicture`,
`TestTheSameCallIdInAnotherTurnIsAnotherPicture`,
`TestTheSameCallAskingForAnotherPictureIsAnotherJob`,
`TestADrawSentThatTimedOutIsCountedAndSaysSo`,
`TestADrawNeverStartedForHalfAnHourPostsThatItTookTooLong`,
`TestADrawThatFinishesAsTheRouterStopsIsKeptNotDrawnAgain`,
`TestAStreamChatRequestTimeoutIsTriedAgain`,
`TestOnlyAStreamChatRefusalALaterTryGetsTooIsPermanent` |
| FAL marks only its slot wait as never started |
`TestADrawThatWaitedOutItsDeadlineForASlotWasNeverStarted`,
`TestADrawThatTimedOutOnceSubmittedWasStarted` |
| A claim of decided jobs loads no picture bytes |
`TestAnImageJobWithItsMessageDecidedIsClaimedOnlyToPost` |
| Usage row under the session's attribution, when drawn |
`TestADrawIsBilledUnderTheSessionThatAskedWhenItIsDrawn` (row cost
40,000 micros) |
| `generate_image` queues and answers at once; `aspect_ratio` an enum
checked in the turn; plain words when it cannot queue |
`TestAPictureIsQueuedAndTheTurnIsAnsweredAtOnce`,
`TestAShapeNoModelDrawsIsRefusedInTheTurn`,
`TestAPictureThatCouldNotBeQueuedIsToldInPlainWords` |
| An `image` target nothing answers to is refused on config save (400);
a lookup that failed stays a server error |
`TestAnImageTargetNothingDrawsWithIsRefused`,
`TestOnlyAnImageTargetNothingAnswersToIsRefused` |
| FAL `queued_ms` / `drawn_ms` on the usage row |
`TestAJobsTimeIsSplitIntoFALsQueueAndTheDrawing`,
`TestAJobFirstSeenFinishedLeavesItsQueueAndDrawingUnknown` |

## Notes for review

- A session still open keeps its in-memory model history; the posted
picture is in it only once the conversation is read again from the
channel (next reopen). The model was told the picture is on its way.
- Thread channels (Slack, SMS): `Post` hands the picture to
`OnFinishedReply` with no command id. By reading,
`channelbridge.Bridge.Reply` then sends it like any reply, claimed by
message id. No test covers it: unverified.
- Failure texts are written to the person, since the model is gone by
the time the worker knows.
- `image_jobs` rows hold prompts: done rows are deleted after a day,
failed after 30 days.
- After #2002 squash-merges, this branch still carries #2002's commits;
it will be brought up to date by merging `origin/accelerate` (no rebase,
no force-push) and regenerating the clients.

## Validation

**CI: `accelerate` is red on two jobs this PR does not touch.**
`integration (internal/memory/mem0)` fails on the shared mem0 account's
monthly retrieval quota (`429 ... Monthly retrieval limit for your Pro
plan reached`, 50,000 used of 50,000, resets 2026-11-13 21:24 UTC), and
`integration (internal/api)` fails `TestMemorySuite` at
`memory_recall_test.go:218`. Both fail the same way on `accelerate`'s
own last two runs (43444872895, 43440533515). Rerunning before the quota
resets would fail again.

- `go build ./... && go vet ./...`: clean.
- Unit, `go test
./internal/{agent,session,api,imagejobs,imagegen/...,imagerouter,store,conversation,chatinbound}
./cmd/router`: all ok.
- Integration (`-tags integration -p 1`), by hand:
`./internal/imagejobs` (24 integration tests and 2 unit) ok,
`./internal/imagerouter` ok (CI skips it), `./internal/chatinbound` ok,
`./internal/conversation` ok, `./internal/agent` ok (771 s), store image
job tests ok, `./cmd/router -run TestDrainSuite` ok, `./internal/api
-run
'TestAppHooksSuite|TestThreadHooks|TestChatInbound|TestConfigsSuite|TestSync|TestMessageHooksSuite|TestImages|TestSessionConnectorsSuite|TestSessionCreateSuite'`
ok.
- `sdks/go`: `go test ./...` ok. The JS and Python loader fixes for
`image:` are on #2002 and come in by merge: JS `npm test` 601 passed,
Python `test_config.py` and `test_folder.py` 210 passed,
`test_config.py` 65 passed after the bare-`image:` fix, mypy and ruff
0.15.20 clean on the changed files.
- An earlier full `./internal/store` run failed once in
`TestProviderAppLocksOnOneAppNeverOverlapAcrossRouters`, a lock test
this change does not touch; it passed 3 of 3 runs on its own.
- `dev.py check` not run: no synced Python env in the worktree.

Mutation checks, each made the named test fail and was reverted:

| Mutation | Caught by |
| --- | --- |
| 16 draws at once instead of 2 |
`TestFourPicturesAtOnceAreAllDrawnTwoAtATime` |
| A draw no provider took treated as a failure |
`TestADrawNoProviderTookInTimeIsTriedAgainUncounted` |
| A permanent upload refusal retried |
`TestAnUploadStreamChatRefusedForGood...` |
| Every Stream error treated as permanent |
`TestAnUploadStreamChatFailedIsTriedAgain...` |
| A failure posted without being decided first |
`TestAFailurePostedButNotRecorded...` |
| A drawn picture not kept | `TestAPostWhoseAnswerWasLost...` |
| Post waits that never run out | `TestAPostThatNeverGetsThrough...` |
| Close keeping the lease | `TestARouterThatStopsMidDraw...` |
| The 2-draw limit ignored | `TestAJobDrawnAsOften...` |
| The claim dropped from the conditional update |
`TestASlowClaimCannotFinish...` |
| Drawings left out of the drain count |
`TestTheDrainWaitsForAPictureBeingDrawn` |
| An unroutable `image` target accepted |
`TestAnImageTargetNothingDrawsWithIsRefused` |
| Queue and draw durations swapped | `TestAJobsTimeIsSplit...` |
| Earlier: a fresh message id per post, no reclaim after the lease |
`TestAPostWhoseAnswerWasLost...`, `TestARouterKilledMidDraw...` |
| 408 treated as permanent | `TestAStreamChatRequestTimeoutIsTriedAgain`
|
| An already-written 400 treated as permanent |
`TestOnlyAStreamChatRefusalALaterTryGetsTooIsPermanent` |
| Untaken read from the timeout code, not the sentinel |
`TestADrawSentThatTimedOutIsCountedAndSaysSo` |
| No 30-minute cutoff for untaken draws |
`TestADrawNeverStartedForHalfAnHour...` |
| Request key of session and call only |
`TestTheSameCallIdInAnotherTurn...`,
`TestTheSameCallAskingForAnotherPicture...` |
| A draw that finished as Close came released and uncounted |
`TestADrawThatFinishesAsTheRouterStops...` |
| Usage id dropped from the meter, or not passed by the job |
`TestAGenerationDrawnTwiceUnderOneUsageIDIsBilledOnce`,
`TestADrawIsBilled...` |
| FAL's slot wait not marked never started |
`TestADrawThatWaitedOutItsDeadlineForASlot...` |
| A failed lookup of the image target answered as a 400 |
`TestOnlyAnImageTargetNothingAnswersToIsRefused` |
| Picture bytes returned by the claim |
`TestAnImageJobWithItsMessageDecidedIsClaimedOnlyToPost` |
| The turn id not passed by the agent |
`TestAPictureIsQueuedAndTheTurnIsAnsweredAtOnce` |
| A bare `image:` read as "turn drawing off" in Python (on #2002) |
`test_a_bare_image_leaves_the_stored_one` |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Laurent Schaffner <laurentschaffner@Laurents-MacBook-Pro-Stream.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Swift's generated.json is regenerated from the merged spec; every other
client regenerated without a change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Loschcode

Copy link
Copy Markdown
Author

/jeffery review

@getstream-jeffery
getstream-jeffery Bot dismissed their stale review November 13, 2026 21:58

Withdrawing approval until 02ff422 is reviewed.

@getstream-jeffery getstream-jeffery Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved 02ff422b0bd2 after 5 rounds · 4 fixed

@Loschcode
Loschcode merged commit 43a70b0 into accelerate Nov 13, 2026
62 checks passed
@Loschcode
Loschcode deleted the laurentschaffner/ai-3201-image-generation-latency branch November 13, 2026 22:20
Loschcode pushed a commit that referenced this pull request Nov 13, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Loschcode added a commit that referenced this pull request Nov 13, 2026
## Why

`POST /v1/image/generations` is served (`alibaba/qwen-image-3` behind
`image-fast`) but documented nowhere, and its shortcuts are missing from
the routes table. From the JavaScript SDK, listing image routes meant
writing `client.get("/v1/{modality}/routes", ...)` by hand, which throws
`needs a modality` when the path parameter is passed the wrong way
(AI-3199, piece 1 of AI-3160).

## How it works

```
api.router().routes("image")            Go: client.Routes(ctx, ModalityImage)
  -> GET /v1/image/routes               (router already serves it: images_test.go:173)
  <- [ image-fast    -> fal/alibaba/qwen-image-3/text-to-image,
       image-quality -> google/gemini-3.1-flash-image ]   default first
```

| Change | Result | Test |
| --- | --- | --- |
| `Client.Routes(ctx, modality)` in `sdks/go/client/routes.go` | One
call lists a modality's shortcuts |
`TestTheImageRoutesAreListedInTheOrderTheRouterGave` |
| `Router.routes(modality)` in `sdks/js/src/router.ts` |
`api.router().routes("image")` | `search > lists a modality's routes,
default first` (resource.test.ts) |
| `## Pictures` in `acceleration/README.md` | Request, response,
options, routes, time, limits, billing | docs |
| `image-fast`, `image-quality` rows in "The default groups" | The
routes table lists the image modality | docs |
| SDK tests serve the routes in an order no sort would give | They prove
the router's order (default first) is kept |
`TestTheImageRoutesAreListedInTheOrderTheRouterGave`, JS `lists a
modality's routes in the order the router gave...` |

Go goes first per AGENTS.md; the sdk skill has a note for Python, .NET,
Ruby, Rust and PHP.

## Not done here

- **The public docs page and the public routes table.** They live in
getstream.io (`content/docs`), outside this repo and outside what this
change was allowed to touch. The README section is the in-repo source
for that page.
- **What its README describes has shipped in #2013 and #2004**, now on
`accelerate`: the agent's `image:` setting and `generate_image`, drawn
in the background and posted when ready, and FAL's
`queued_ms`/`drawn_ms` on the usage row (#2013); this branch is merged
with them. Its code (the SDK `routes` methods) depends on nothing.
- The `401 api_key is required` in the ticket comes from the gateway in
front of the router, not the router (the `connectors` skill lists that
body as the gateway's). The SDK sends the credential that proxy wants
(an `api_key` header, `sdks/js/src/backend.ts:224`), which a raw request
without it lacks. Whether the proxy passes `/v1/image/routes` was not
checked against staging.

## Validation

- `cd sdks/go && go test ./...`: all packages ok. The new test fails if
`Routes` asks for another path or modality.
- `cd sdks/js && npm test` on Node 22: 601 tests, 0 failed.
- The README's curl was not run against a live router (unverified). Its
facts come from code: `images.go`, `imagegen.go:30-39` (4 pictures, 10
MiB, 4096 px), `fal.go:43-58` (3 s poll, 2 jobs per process, 512-2048
px), `router.yaml` image section, `prices/vendor.json`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Laurent Schaffner <laurentschaffner@Laurents-MacBook-Pro-Stream.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant