From 3a7ad4d7c38b8dbce977603cc277bf07e2260d2a Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 9 Oct 2026 18:21:16 -0700 Subject: [PATCH 01/13] chore(git): ignore agent task worktrees Task worktrees live under .worktrees/ in the repository root; ignoring the directory keeps them out of the primary checkout's status. - Add .worktrees/ to .gitignore Agent-Session: 56561a72-cfab-48d1-b4ea-4e436939d198 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index acb92611..feb5f3d0 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,5 @@ screenshots/review/ screenshots/asc-review/ ### Stray debug/dogfood captures at the screenshots root (annotated browser shots) ### /screenshots/*.png +### Agent task worktrees ### +.worktrees/ From 049db3dc311bed5874d1114fdf9b43b4128c0105 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 9 Oct 2026 18:21:21 -0700 Subject: [PATCH 02/13] build(apple): raise the iOS floor to 27 and the macOS floor to 26 The Apple shells now support only iOS 27 and macOS 26 and later, so the pre-macOS-26 toolbar layout and per-target deployment settings are dead. - Set IPHONEOS_DEPLOYMENT_TARGET 27.0 and MACOSX_DEPLOYMENT_TARGET 26.0 once at project level; app and test targets inherit them - Keep the desktop build universal (Intel and Apple silicon) - Delete the pre-macOS-26 primaryAction toolbar fallback in WebShellView - Document the minimums in mobile/README.md Agent-Session: 56561a72-cfab-48d1-b4ea-4e436939d198 --- mobile/README.md | 3 ++- .../JavaChatDesktop.xcodeproj/project.pbxproj | 8 ++------ .../desktopApp/JavaChatDesktop/WebShellView.swift | 14 ++++---------- mobile/iosApp/JavaChat.xcodeproj/project.pbxproj | 8 ++------ 4 files changed, 10 insertions(+), 23 deletions(-) diff --git a/mobile/README.md b/mobile/README.md index 00e8ce7b..e0deabc3 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -10,7 +10,8 @@ authentication session and return through `javachat://sso-callback`; the typed callback is then completed by the canonical Clerk client in the hosted app. The desktop shell adds standard macOS Copy Link and Open in Default Browser -actions. The iOS target is a universal iPhone and iPad app. It follows the +actions and requires macOS 26 on Intel or Apple silicon. The iOS target is a +universal iPhone and iPad app that requires iOS 27. It follows the installed-current simulator policy: use a compatible simulator already installed on the Mac and identify it by UDID. diff --git a/mobile/desktopApp/JavaChatDesktop.xcodeproj/project.pbxproj b/mobile/desktopApp/JavaChatDesktop.xcodeproj/project.pbxproj index 418a0fc6..d6c2a749 100644 --- a/mobile/desktopApp/JavaChatDesktop.xcodeproj/project.pbxproj +++ b/mobile/desktopApp/JavaChatDesktop.xcodeproj/project.pbxproj @@ -240,7 +240,7 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = macosx; SWIFT_VERSION = 6.0; }; @@ -250,7 +250,7 @@ isa = XCBuildConfiguration; buildSettings = { CLANG_ENABLE_MODULES = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; + MACOSX_DEPLOYMENT_TARGET = 26.0; SDKROOT = macosx; SWIFT_VERSION = 6.0; }; @@ -269,7 +269,6 @@ ENABLE_TESTABILITY = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = JavaChatDesktop/Info.plist; - MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = ai.javachat; PRODUCT_MODULE_NAME = JavaChatDesktop; @@ -291,7 +290,6 @@ ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = JavaChatDesktop/Info.plist; - MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = ai.javachat; PRODUCT_MODULE_NAME = JavaChatDesktop; @@ -309,7 +307,6 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = NB356A75PU; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = ai.javachat.desktop.tests; PRODUCT_MODULE_NAME = JavaChatDesktopTests; @@ -329,7 +326,6 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = NB356A75PU; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = ai.javachat.desktop.tests; PRODUCT_MODULE_NAME = JavaChatDesktopTests; diff --git a/mobile/desktopApp/JavaChatDesktop/WebShellView.swift b/mobile/desktopApp/JavaChatDesktop/WebShellView.swift index 7a05fa7b..93a1e265 100644 --- a/mobile/desktopApp/JavaChatDesktop/WebShellView.swift +++ b/mobile/desktopApp/JavaChatDesktop/WebShellView.swift @@ -27,17 +27,11 @@ struct WebShellView: View { .toolbarBackgroundVisibility(.visible, for: .windowToolbar) .toolbar(removing: .title) .toolbar { - if #available(macOS 26.0, *) { - ToolbarSpacer(.flexible) - ToolbarItem { - pageActionButtons - } - .sharedBackgroundVisibility(.hidden) - } else { - ToolbarItem(placement: .primaryAction) { - pageActionButtons - } + ToolbarSpacer(.flexible) + ToolbarItem { + pageActionButtons } + .sharedBackgroundVisibility(.hidden) } } diff --git a/mobile/iosApp/JavaChat.xcodeproj/project.pbxproj b/mobile/iosApp/JavaChat.xcodeproj/project.pbxproj index d93f1656..ae072ffb 100644 --- a/mobile/iosApp/JavaChat.xcodeproj/project.pbxproj +++ b/mobile/iosApp/JavaChat.xcodeproj/project.pbxproj @@ -231,7 +231,6 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = JavaChat/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = ai.javachat; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -248,7 +247,6 @@ DEVELOPMENT_TEAM = NB356A75PU; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = JavaChat/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = ai.javachat; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -264,7 +262,6 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = NB356A75PU; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; PRODUCT_BUNDLE_IDENTIFIER = ai.javachat.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -276,7 +273,6 @@ CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = NB356A75PU; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; PRODUCT_BUNDLE_IDENTIFIER = ai.javachat.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; @@ -304,7 +300,7 @@ GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; @@ -330,7 +326,7 @@ ENABLE_NS_ASSERTIONS = NO; GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; + IPHONEOS_DEPLOYMENT_TARGET = 27.0; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; }; From cde5608845537edcde8be1b26fd55030a4e4f325 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 9 Oct 2026 18:23:29 -0700 Subject: [PATCH 03/13] fix(test): isolate the pinned-clone metadata test from hook git variables Run from the pre-push hook, the test inherited GIT_DIR, so its scratch `git init` and `remote add origin` targeted the pushing repository: the push failed with "remote origin already exists" and the re-init set core.bare=true on the primary checkout. - Unset every variable `git rev-parse --local-env-vars` lists before the test touches its scratch clone Agent-Session: 56561a72-cfab-48d1-b4ea-4e436939d198 --- scripts/test_github_repository_metadata_contract.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/test_github_repository_metadata_contract.sh b/scripts/test_github_repository_metadata_contract.sh index 4841ac33..ef7966a6 100755 --- a/scripts/test_github_repository_metadata_contract.sh +++ b/scripts/test_github_repository_metadata_contract.sh @@ -7,6 +7,12 @@ set -euo pipefail +# Git hooks export GIT_DIR and related variables; inherited, they would point +# every git command below at the pushing repository instead of the scratch clone. +while IFS= read -r repository_local_variable; do + unset "$repository_local_variable" +done < <(git rev-parse --local-env-vars) + TEST_SCRIPT_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TEST_WORK_DIRECTORY="$(mktemp -d)" FAKE_BINARY_DIRECTORY="$TEST_WORK_DIRECTORY/bin" From a0009b719bb0c0283d7553784e4168f4be6f1e49 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 9 Oct 2026 18:49:04 -0700 Subject: [PATCH 04/13] build(apple): compile every xcodebuild lane through the shared per-user compilation cache XCODE_XCCONFIG_FILE loads mobile/compilation-cache.xcconfig, byte-identical to the back-end and researchly copies, so an empty DerivedData replays earlier compiles instead of rebuilding cold. Agent-Session: 56561a72-cfab-48d1-b4ea-4e436939d198 --- mobile/Makefile | 3 +++ mobile/compilation-cache.xcconfig | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 mobile/compilation-cache.xcconfig diff --git a/mobile/Makefile b/mobile/Makefile index 73dada86..35e3e024 100644 --- a/mobile/Makefile +++ b/mobile/Makefile @@ -12,6 +12,9 @@ ios-archive \ ios-export-app-store +# Every xcodebuild lane compiles through Xcode's shared per-user compilation cache. +export XCODE_XCCONFIG_FILE := $(CURDIR)/compilation-cache.xcconfig + DEVELOPMENT_TEAM := NB356A75PU ASC_PROFILE := csweb DEVELOPER_ID_APPLICATION := Developer ID Application: William Callahan (NB356A75PU) diff --git a/mobile/compilation-cache.xcconfig b/mobile/compilation-cache.xcconfig new file mode 100644 index 00000000..5847585a --- /dev/null +++ b/mobile/compilation-cache.xcconfig @@ -0,0 +1,6 @@ +// Loaded by every Make lane through XCODE_XCCONFIG_FILE, so package targets are +// cached too. The cache sits at Xcode's per-user default, not in each lane's +// DerivedData, so a lane rebuilt from an empty DerivedData replays what it +// compiled before, and every lane and checkout reuses compiled packages. +COMPILATION_CACHE_ENABLE_CACHING = YES +COMPILATION_CACHE_CAS_PATH = $(HOME)/Library/Developer/Xcode/DerivedData/CompilationCache.noindex From 06c53a0de593455f57c0135e8145aa05d15ad19b Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 9 Oct 2026 19:28:17 -0700 Subject: [PATCH 05/13] build(mobile): unlock the signing keychain before every signed lane desktop-archive, desktop-create-signed-dmg, and ios-archive now start with signing-session, which runs the shared host tool build-keychain unlock, so they sign from a fresh SSH session. Agent-Session: 56561a72-cfab-48d1-b4ea-4e436939d198 --- mobile/Makefile | 13 ++++++++++--- mobile/README.md | 5 +++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/mobile/Makefile b/mobile/Makefile index 35e3e024..6addbbbe 100644 --- a/mobile/Makefile +++ b/mobile/Makefile @@ -1,4 +1,5 @@ .PHONY: \ + signing-session \ desktop \ desktop-test \ desktop-archive \ @@ -30,6 +31,12 @@ IOS_DISTRIBUTION_DIR := iosApp/distribution IOS_ARCHIVE := $(IOS_DISTRIBUTION_DIR)/JavaChat.xcarchive IOS_APP_STORE_EXPORT := $(IOS_DISTRIBUTION_DIR)/app-store +# Signed lanes unlock this Mac's build keychain through the host tool build-keychain (dotfiles, +# ~/.local/bin), so they run from a fresh SSH session with no console login [HH0a]; a Mac without +# one signs from its open login keychain. CI never runs a signed lane. +signing-session: + @build-keychain unlock + desktop: xcodebuild \ -project desktopApp/JavaChatDesktop.xcodeproj \ @@ -48,7 +55,7 @@ desktop-test: -derivedDataPath desktopApp/build-test \ test CODE_SIGNING_ALLOWED=NO -desktop-archive: +desktop-archive: signing-session xcodebuild \ -project desktopApp/JavaChatDesktop.xcodeproj \ -scheme JavaChatDesktop \ @@ -75,7 +82,7 @@ desktop-export-developer-id: desktop-archive desktop-dmg: desktop-export-developer-id $(MAKE) desktop-create-signed-dmg -desktop-create-signed-dmg: +desktop-create-signed-dmg: signing-session rm -rf "$(DESKTOP_DMG_STAGING)" mkdir -p "$(DESKTOP_DMG_STAGING)" ditto \ @@ -138,7 +145,7 @@ ios-test: -derivedDataPath iosApp/build-test \ test CODE_SIGNING_ALLOWED=NO -ios-archive: +ios-archive: signing-session xcodebuild \ -project iosApp/JavaChat.xcodeproj \ -scheme JavaChat \ diff --git a/mobile/README.md b/mobile/README.md index e0deabc3..a5689e4d 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -64,6 +64,11 @@ packaging it, then submits the signed DMG to Apple's Notary service and staples and validates the outer ticket. Both layers therefore retain offline notarization evidence after the app is copied out of the disk image. +Every signing lane (`desktop-archive`, `ios-archive`, `desktop-create-signed-dmg`, +and the exports built on them) starts with `signing-session`, which runs the +dotfiles host tool `build-keychain unlock`, so it signs from a fresh +noninteractive SSH session. + These lanes require the matching Developer ID or Apple Distribution identities, profiles, a valid `csweb` App Store Connect profile, and signed agreements for the `william@csweb.io` account. They intentionally fail instead of falling back From 19ef5e88509992e3840aed36454d7950ac510bd0 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 9 Oct 2026 20:30:38 -0700 Subject: [PATCH 06/13] build(mobile): run every Apple lane on haiku-7 from any other host Off haiku-7 (and outside GitHub Actions), mobile/Makefile forwards its goals and shell-quoted command-line variables to the dotfiles host tool remote-slot, which mirrors the checkout's exact bytes into a warm .worktrees/remote-slot- on haiku-7 and runs the lane there, sharing that host's Swift package clones and compilation cache. Agent-Session: 56561a72-cfab-48d1-b4ea-4e436939d198 --- mobile/Makefile | 18 ++++++++++++++++++ mobile/README.md | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/mobile/Makefile b/mobile/Makefile index 6addbbbe..9a9e82e9 100644 --- a/mobile/Makefile +++ b/mobile/Makefile @@ -1,3 +1,20 @@ +# Every lane runs on the Mac build host. Any other host (the laptop, Linux peers) forwards the goals +# and command-line variables, each shell-quoted, to a warm mirror of this checkout there through the +# host tool remote-slot (dotfiles, ~/.local/bin). The environment, gitignored files, and make's own +# flags do not cross, and every output (installs, archives, receipts) stays on that host. GitHub +# Actions jobs already run on the Mac their workflow picked. +APPLE_BUILD_HOST := haiku-7 +ifeq ($(filter $(APPLE_BUILD_HOST),$(shell hostname -s))$(GITHUB_ACTIONS),) +APPLE_BUILD_HOST_VARIABLES := $(strip $(foreach variable,$(sort $(.VARIABLES)),$(if $(filter command line,$(origin $(variable))),'$(variable)=$(subst ','\'',$(value $(variable)))'))) +.DEFAULT_GOAL := apple-build-host +.PHONY: apple-build-host $(MAKECMDGOALS) +apple-build-host: + remote-slot run $(APPLE_BUILD_HOST) -- make $(MAKECMDGOALS) $(APPLE_BUILD_HOST_VARIABLES) +ifneq ($(MAKECMDGOALS),) +$(MAKECMDGOALS): apple-build-host ; @: +endif +else + .PHONY: \ signing-session \ desktop \ @@ -162,3 +179,4 @@ ios-export-app-store: ios-archive -exportPath $(IOS_APP_STORE_EXPORT) \ -exportOptionsPlist iosApp/ExportOptions-AppStore.plist \ -allowProvisioningUpdates +endif diff --git a/mobile/README.md b/mobile/README.md index a5689e4d..87eac690 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -69,6 +69,11 @@ and the exports built on them) starts with `signing-session`, which runs the dotfiles host tool `build-keychain unlock`, so it signs from a fresh noninteractive SSH session. +`haiku-7` runs every lane in `mobile/Makefile`. From any other host, `make -C mobile ` +forwards the goals and command-line variables to the dotfiles host tool `remote-slot`, which +mirrors this checkout's exact bytes into a warm `.worktrees/remote-slot-` on `haiku-7` and runs +the lane there; its outputs stay on `haiku-7`. + These lanes require the matching Developer ID or Apple Distribution identities, profiles, a valid `csweb` App Store Connect profile, and signed agreements for the `william@csweb.io` account. They intentionally fail instead of falling back From d47969ce67e98bd3d5d019f1218bc14197834e23 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 14 Oct 2026 15:35:08 -0700 Subject: [PATCH 07/13] feat(llm): make Haiku 5.5 the tested chat default Agent-Session: 01a13c7d-3b3c-7bd3-be65-ea9d2f6811a5 --- .env.example | 2 +- .github/workflows/build.yml | 8 ++++++-- AGENTS.md | 2 +- build.gradle.kts | 2 +- config/ast-grep/java-no-obsolete-gpt5-chat-model.yml | 2 +- docs/api.md | 2 +- docs/configuration.md | 6 +++--- docs/getting-started.md | 2 +- docs/retrieval-pipeline.md | 6 +++--- frontend/src/lib/components/ThinkingIndicator.svelte | 2 +- scripts/lint/check-chat-model-ssot.rb | 8 ++++---- .../javachat/config/ModelConfiguration.java | 2 +- .../com/williamcallahan/javachat/service/ChatService.java | 2 +- src/main/resources/application.properties | 8 ++++---- .../javachat/service/OpenAiRequestFactoryTest.java | 6 +++--- 15 files changed, 32 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 92ce6328..d8eb242e 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,7 @@ PORT=8085 OPENAI_API_KEY= # Chat and embeddings share the gateway base URL and credential. OPENAI_BASE_URL=https://api.llm-gateway.iocloudhost.net/v1 -OPENAI_MODEL=gpt-5.4 +OPENAI_MODEL=claude-haiku-5-5 # Local Embedding Server (if you have one running) APP_LOCAL_EMBEDDING_ENABLED=false LOCAL_EMBEDDING_SERVER_URL=http://127.0.0.1:8088 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5c81db1c..54508a9a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,7 @@ env: jobs: writer-lease-portability: runs-on: macos-15 + timeout-minutes: 30 steps: - name: Checkout code @@ -37,6 +38,7 @@ jobs: frontend: runs-on: ubuntu-24.04 + timeout-minutes: 30 defaults: run: working-directory: frontend @@ -86,6 +88,7 @@ jobs: build: runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout code @@ -151,6 +154,7 @@ jobs: - frontend - build runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout code @@ -210,7 +214,7 @@ jobs: --publish 8085:8085 \ --env OPENAI_API_KEY=ci-smoke-token \ --env OPENAI_BASE_URL=https://api.llm-gateway.iocloudhost.net/v1 \ - --env OPENAI_MODEL=gpt-5.4 \ + --env OPENAI_MODEL=claude-haiku-5-5 \ --env APP_LOCAL_EMBEDDING_ENABLED=true \ --env SPRING_PROFILES_ACTIVE=ci \ --env QDRANT_HOST=qdrant \ @@ -286,7 +290,7 @@ jobs: --network java-chat-ci_default \ --env OPENAI_API_KEY=ci-smoke-token \ --env OPENAI_BASE_URL=https://api.llm-gateway.iocloudhost.net/v1 \ - --env OPENAI_MODEL=gpt-5.4 \ + --env OPENAI_MODEL=claude-haiku-5-5 \ --env APP_LOCAL_EMBEDDING_ENABLED=true \ --env SPRING_PROFILES_ACTIVE=ci \ --env SPRING_APPLICATION_NAME="$conflicting_runtime_name" \ diff --git a/AGENTS.md b/AGENTS.md index 656d4b4c..8587fbb9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -256,7 +256,7 @@ alwaysApply: true - [LM1a] **Settings**: Do not change any LLM settings without explicit written approval. - [LM1b] **No Fallback**: Do not auto-fallback or regress models across providers; surface error to user. -- [LM1c] **Config**: Generative inference MUST go through the shared OpenAI-compatible gateway using a non-blank `OPENAI_MODEL` gateway alias (default `gpt-5.4`). Its model/base-url/api-key MUST come from `.env`/environment variables (see [EV1d]). All other LLM settings MUST come from Spring property files and `@ConfigurationProperties`. +- [LM1c] **Config**: Generative inference MUST go through the shared OpenAI-compatible gateway using a non-blank `OPENAI_MODEL` gateway alias (default `claude-haiku-5-5`). Its model/base-url/api-key MUST come from `.env`/environment variables (see [EV1d]). All other LLM settings MUST come from Spring property files and `@ConfigurationProperties`. - [LM1d] **Behavior**: Allowed: logging diagnostics. Not allowed: silently changing LLM behavior. - [LM1e] **Streaming**: TTFB < 200ms, streaming start < 500ms. - [LM1f] **Events**: `text`, `citation`, `code`, `enrichment`, `suggestion`, `status`. diff --git a/build.gradle.kts b/build.gradle.kts index 0bfd7bc5..4a27d9b1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -182,7 +182,7 @@ tasks.withType { showStackTraces = true } systemProperty("spring.profiles.active", "test") - environment("OPENAI_MODEL", "gpt-5.4") + environment("OPENAI_MODEL", "claude-haiku-5-5") useJUnitPlatform() maxHeapSize = "1024m" jvmArgs( diff --git a/config/ast-grep/java-no-obsolete-gpt5-chat-model.yml b/config/ast-grep/java-no-obsolete-gpt5-chat-model.yml index eb45d1c7..bfc67b44 100644 --- a/config/ast-grep/java-no-obsolete-gpt5-chat-model.yml +++ b/config/ast-grep/java-no-obsolete-gpt5-chat-model.yml @@ -7,7 +7,7 @@ language: Java severity: error message: | [LM1a-c] Obsolete GPT-5 chat-model identifier detected. - Use GPT-5.4 through ModelConfiguration; never restore another GPT-5 default. + Use the selected gateway model through ModelConfiguration; never restore an obsolete GPT-5 default. files: - 'src/main/java/**' rule: diff --git a/docs/api.md b/docs/api.md index 69dc5cb1..19a527ec 100644 --- a/docs/api.md +++ b/docs/api.md @@ -47,7 +47,7 @@ and hybrid search across the indexed JDK documentation, books, and GitHub reposi events carry `message`/`details` only (`code`, `retryable`, and `stage` are `null`) and always arrive before the `provider` event. -Chat uses the shared OpenAI-compatible gateway with GPT-5.4. An unavailable, rate-limited, +Chat uses the shared OpenAI-compatible gateway with Claude Haiku 5.5 at low reasoning effort. An unavailable, rate-limited, or failed gateway request terminates the stream with an `error` event; no alternate provider exists. Example: diff --git a/docs/configuration.md b/docs/configuration.md index a9cfe7c3..7795cc9c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,10 +19,10 @@ Common variables: - `OPENAI_API_KEY` (shared-gateway auth) - `OPENAI_BASE_URL` (`https://api.llm-gateway.iocloudhost.net/v1` for Java Chat deployments) -- `OPENAI_MODEL` (non-blank gateway chat alias; default `gpt-5.4`) +- `OPENAI_MODEL` (non-blank gateway chat alias; default `claude-haiku-5-5`) - `OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS` (default `90`; bounds the complete SDK call and the visible-output deadline, which shares this same budget) -Non-secret generation policy is owned by `app.llm` in `application.properties`: `temperature`, optional `reasoning-effort`, `completion-output-token-budget`, `enrichment-output-token-budget`, `reranker-temperature`, `reranker-output-token-budget`, and `configured-provider-backoff-seconds`. `gpt-5.4` is a reasoning generation whose upstream accepts only the default temperature (`1.0`) on the Responses API, so both temperature settings stay at `1.0`; any other value fails upstream with `HTTP 400`. `reasoning-effort` is absent by default, preserving the gateway/model default; set it only for an explicit override. Invalid values fail startup. The override applies only to chat generation (streaming and text completions); JSON-object completions (reranker, enrichment) always inherit the gateway/model default so reasoning tokens cannot starve their small output budgets. Java Chat forwards the universal reasoning values `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max` without model inspection; the gateway owns upstream capability translation. A deterministic gateway `HTTP 422 unpreservable_reasoning_intent` means no provider can preserve the configured effort — lower or unset `reasoning-effort`; retrying unchanged cannot succeed. +Non-secret generation policy is owned by `app.llm` in `application.properties`: `temperature`, `reasoning-effort`, `completion-output-token-budget`, `enrichment-output-token-budget`, `reranker-temperature`, `reranker-output-token-budget`, and `configured-provider-backoff-seconds`. The tested default is Claude Haiku 5.5 with `reasoning-effort=low`, which preserved quality while reducing latency and cost in the Java Chat matrix; both temperature settings stay at `1.0` and the gateway owns provider-specific sampling constraints. The override applies only to chat generation (streaming and text completions); JSON-object completions (reranker, enrichment) always inherit the gateway/model default so reasoning tokens cannot starve their small output budgets. Java Chat forwards the universal reasoning values `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max` without model inspection; the gateway owns upstream capability translation. A deterministic gateway `HTTP 422 unpreservable_reasoning_intent` means no provider can preserve the configured effort — lower or unset `reasoning-effort`; retrying unchanged cannot succeed. ### Shared LLM gateway @@ -32,7 +32,7 @@ Configure chat through the shared gateway with: OPENAI_API_KEY=lgw-... OPENAI_BASE_URL=https://api.llm-gateway.iocloudhost.net/v1 # Default; any non-blank gateway alias is supported. -OPENAI_MODEL=gpt-5.4 +OPENAI_MODEL=claude-haiku-5-5 ``` Java Chat sends chat and embedding requests to the same configured gateway URL and credential. diff --git a/docs/getting-started.md b/docs/getting-started.md index da82cc23..dadbe254 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -20,7 +20,7 @@ cp .env.example .env `java-chat-qwen3-embedding-4b-2560-*` collection names, and repository-local generation state roots. Configure the shared gateway `OPENAI_BASE_URL` and `OPENAI_API_KEY` used by embeddings and gateway chat. -Set `OPENAI_API_KEY`, `OPENAI_BASE_URL`, and a non-blank gateway `OPENAI_MODEL` alias (the default is `gpt-5.4`). +Set `OPENAI_API_KEY`, `OPENAI_BASE_URL`, and a non-blank gateway `OPENAI_MODEL` alias (the default is `claude-haiku-5-5`). Java Chat uses the shared gateway only and does not dispatch failed requests to another provider. Leave `app.llm.reasoning-effort` unset to preserve the gateway/model default; set one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` only for an explicit override. diff --git a/docs/retrieval-pipeline.md b/docs/retrieval-pipeline.md index 62c17108..1bc0fab1 100644 --- a/docs/retrieval-pipeline.md +++ b/docs/retrieval-pipeline.md @@ -252,15 +252,15 @@ When the quality message contains "less relevant" or "keyword search", an additi ### Token budgets -The GPT-5.4 prompt budget is application-owned: +The shared gateway chat prompt budget is application-owned: | Provider/model | Token budget | Constant | |---|---|---| -| Shared gateway GPT-5.4 | 100,000 | `GPT54_INPUT_TOKEN_BUDGET` | +| Shared gateway Claude Haiku 5.5 | 100,000 | `CHAT_INPUT_TOKEN_BUDGET` | Token estimation uses a conservative `(text.length() / 4) + 1` approximation (~4 characters per token for English text). -GPT-5.4 RAG retrieval is reduced upstream to max 3 documents (`RAG_LIMIT_CONSTRAINED`) +Constrained chat retrieval is reduced upstream to max 3 documents (`RAG_LIMIT_CONSTRAINED`) with max 600 tokens each (`RAG_TOKEN_LIMIT_CONSTRAINED`), defined in `ModelConfiguration.java`. --- diff --git a/frontend/src/lib/components/ThinkingIndicator.svelte b/frontend/src/lib/components/ThinkingIndicator.svelte index 1e5107cf..9806a268 100644 --- a/frontend/src/lib/components/ThinkingIndicator.svelte +++ b/frontend/src/lib/components/ThinkingIndicator.svelte @@ -15,7 +15,7 @@ interface Props { /** Primary status message (e.g., "Searching the Java documentation index") */ statusMessage?: string | null - /** Secondary details (e.g., "Embedding your question… · Provider: gpt-5.4") */ + /** Secondary details (e.g., "Embedding your question… · Provider: claude-haiku-5-5") */ statusDetails?: string | null /** Whether we've started receiving content (transitions to "generating" state) */ hasContent?: boolean diff --git a/scripts/lint/check-chat-model-ssot.rb b/scripts/lint/check-chat-model-ssot.rb index cd79ab27..fb6cdc55 100644 --- a/scripts/lint/check-chat-model-ssot.rb +++ b/scripts/lint/check-chat-model-ssot.rb @@ -2,7 +2,7 @@ # frozen_string_literal: true CANONICAL_MODEL_PATH = "src/main/java/com/williamcallahan/javachat/config/ModelConfiguration.java" -EXPECTED_CHAT_MODEL = "gpt-5.4" +EXPECTED_CHAT_MODEL = "claude-haiku-5-5" SCAN_EXCLUSIONS = %r{\A(?:AGENTS\.md\z|CLAUDE\.md\z|src/test/|config/ast-grep(?:-tests)?/|scripts/lint/check-chat-model-ssot\.rb\z)} OPENAI_MODEL_ASSIGNMENT = /OPENAI_MODEL\s*(?::|=|"\s*,\s*")\s*([A-Za-z0-9_.\/-]+)/ @@ -16,15 +16,15 @@ def violations_for_text(text, canonical_model) end def run_self_test - raise "valid canonical model rejected" unless violations_for_text("OPENAI_MODEL=gpt-5.4", EXPECTED_CHAT_MODEL).empty? + raise "valid canonical model rejected" unless violations_for_text("OPENAI_MODEL=claude-haiku-5-5", EXPECTED_CHAT_MODEL).empty? raise "stale model accepted" unless violations_for_text("OPENAI_MODEL=gpt-5", EXPECTED_CHAT_MODEL) == ["gpt-5"] - qualified_model = "provider/gpt-5.4" + qualified_model = "provider/claude-haiku-5-5" unless violations_for_text("OPENAI_MODEL=#{qualified_model}", EXPECTED_CHAT_MODEL) == [qualified_model] raise "provider-qualified model accepted" end comma_form_stale = 'environment("OPENAI_MODEL", "gpt-5")' raise "comma-form stale model accepted" unless violations_for_text(comma_form_stale, EXPECTED_CHAT_MODEL) == ["gpt-5"] - comma_form_canonical = 'environment("OPENAI_MODEL", "gpt-5.4")' + comma_form_canonical = 'environment("OPENAI_MODEL", "claude-haiku-5-5")' raise "comma-form canonical model rejected" unless violations_for_text(comma_form_canonical, EXPECTED_CHAT_MODEL).empty? raise "GitHub Models inference marker accepted" unless "GitHub Models".match?(GITHUB_MODELS_INFERENCE_MARKER) end diff --git a/src/main/java/com/williamcallahan/javachat/config/ModelConfiguration.java b/src/main/java/com/williamcallahan/javachat/config/ModelConfiguration.java index c6a86345..8f910aa6 100644 --- a/src/main/java/com/williamcallahan/javachat/config/ModelConfiguration.java +++ b/src/main/java/com/williamcallahan/javachat/config/ModelConfiguration.java @@ -8,7 +8,7 @@ */ public final class ModelConfiguration { /** Default model identifier when none is configured. */ - public static final String DEFAULT_MODEL = "gpt-5.4"; + public static final String DEFAULT_MODEL = "claude-haiku-5-5"; /** Estimated characters per token for conservative token counting. */ public static final int ESTIMATED_CHARS_PER_TOKEN = 4; diff --git a/src/main/java/com/williamcallahan/javachat/service/ChatService.java b/src/main/java/com/williamcallahan/javachat/service/ChatService.java index c19ad6c4..d12fe59d 100644 --- a/src/main/java/com/williamcallahan/javachat/service/ChatService.java +++ b/src/main/java/com/williamcallahan/javachat/service/ChatService.java @@ -189,7 +189,7 @@ public StructuredPromptOutcome buildStructuredPromptWithContextOutcome( List contextDocs = retrieveTokenConstrainedOfficialDocumentation( latestUserMessage, retrievalProgressListener, stageDeadlineNanos); logger.debug( - "Using GPT-5.4 retrieval context: {} documents with max {} tokens each", + "Using constrained retrieval context: {} documents with max {} tokens each", contextDocs.size(), ModelConfiguration.RAG_TOKEN_LIMIT_CONSTRAINED); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8b1c4d06..94e3d301 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -23,8 +23,8 @@ spring.http.codecs.max-in-memory-size=${SPRING_HTTP_CODECS_MAX_IN_MEMORY_SIZE:1M # Base URLs are used by the SDK directly (not Spring AI). # Base URL defaults are owned by OpenAIStreamingService; model defaults are owned by # OpenAiRequestFactory and ModelConfiguration. Environment variables override them. -# OPENAI_MODEL is a non-blank gateway chat alias; gpt-5.4 is only its default. -# Leave app.llm.reasoning-effort unset to preserve the gateway/model default; set it only for an explicit override. +# OPENAI_MODEL is a non-blank gateway chat alias; claude-haiku-5-5 is only its default. +# The tested Haiku 5.5 profile uses low reasoning effort for the best quality/cost/latency balance. # Streaming timeout: OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS (default: 90). # Java Chat enforces a 20-second visible-output deadline; the gateway owns lower-level transport policy. # Java Chat sends chat to the configured gateway and does not implement client-side provider fallback. @@ -73,10 +73,10 @@ app.rag.search-mmr-lambda=${RAG_MMR_LAMBDA:0.5} app.rag.reranker-timeout=${RAG_RERANKER_TIMEOUT:8s} # LLM defaults used by openai-java streaming service -# gpt-5.4 is a reasoning generation: the upstream accepts only the default temperature (1.0) -# on the Responses API and answers HTTP 400 to any other value. +# The gateway owns provider-specific sampling and reasoning constraints; keep both temperatures at 1.0. app.llm.temperature=1.0 app.llm.reranker-temperature=1.0 +app.llm.reasoning-effort=low app.llm.completion-output-token-budget=4000 app.llm.enrichment-output-token-budget=4000 app.llm.reranker-output-token-budget=512 diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java index a0fd8a4c..a88f88f2 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java @@ -40,7 +40,7 @@ class OpenAiRequestFactoryTest { @Test void defaultGatewayChatModelIsGpt54() { - assertEquals("gpt-5.4", ModelConfiguration.DEFAULT_MODEL); + assertEquals("claude-haiku-5-5", ModelConfiguration.DEFAULT_MODEL); } @Test @@ -155,7 +155,7 @@ void jsonCompletionOmitsTheConfiguredReasoningEffort() { } @Test - void applicationDefaultsLeaveReasoningEffortUnset() throws IOException { + void applicationDefaultsUseTestedHaikuReasoningEffort() throws IOException { Properties applicationProperties = new Properties(); InputStream applicationPropertiesStream = OpenAiRequestFactoryTest.class.getResourceAsStream("/application.properties"); @@ -165,7 +165,7 @@ void applicationDefaultsLeaveReasoningEffortUnset() throws IOException { applicationProperties.load(applicationPropertiesStream); } - assertFalse(applicationProperties.containsKey("app.llm.reasoning-effort")); + assertEquals("low", applicationProperties.getProperty("app.llm.reasoning-effort")); } @Test From 9f37237a036bd68017996468fb47c65863fba5d3 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 14 Oct 2026 15:38:14 -0700 Subject: [PATCH 08/13] test(llm): align default model assertion name Agent-Session: 01a13c7d-3b3c-7bd3-be65-ea9d2f6811a5 --- .../javachat/service/OpenAiRequestFactoryTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java index a88f88f2..b65ddc9b 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java @@ -39,7 +39,7 @@ class OpenAiRequestFactoryTest { private static final int TEST_COMPLETION_OUTPUT_TOKEN_BUDGET = 768; @Test - void defaultGatewayChatModelIsGpt54() { + void defaultGatewayChatModelIsClaudeHaiku55() { assertEquals("claude-haiku-5-5", ModelConfiguration.DEFAULT_MODEL); } From b1d4e5ad01da8e3a42b9d781cd9b2a6fea28e161 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 14 Oct 2026 16:14:29 -0700 Subject: [PATCH 09/13] fix(llm): attribute Java Chat gateway workloads Make Java Chat's chat and embedding traffic visible as source and operation workloads in the shared gateway ledger. - Add X-User-ID labels alongside the existing X-Tier headers. - Cover live and batch embedding labels at the HTTP boundary. Agent-Session: 01a13c59-f542-7652-ad75-21c177f6e2fd --- .../javachat/service/LlmGatewayTier.java | 10 ++++++++++ .../javachat/service/OpenAIStreamingService.java | 2 ++ .../service/OpenAiCompatibleEmbeddingClient.java | 1 + .../OpenAiCompatibleEmbeddingClientWireTest.java | 5 +++++ 4 files changed, 18 insertions(+) diff --git a/src/main/java/com/williamcallahan/javachat/service/LlmGatewayTier.java b/src/main/java/com/williamcallahan/javachat/service/LlmGatewayTier.java index 086ed830..41108e16 100644 --- a/src/main/java/com/williamcallahan/javachat/service/LlmGatewayTier.java +++ b/src/main/java/com/williamcallahan/javachat/service/LlmGatewayTier.java @@ -15,6 +15,8 @@ public enum LlmGatewayTier { /** HTTP header used by the gateway to classify request capacity. */ public static final String REQUEST_TIER_HEADER = "X-Tier"; + /** HTTP header used by the gateway to attribute the request workload. */ + public static final String REQUEST_WORKLOAD_HEADER = "X-User-ID"; private final String requestHeader; @@ -30,4 +32,12 @@ public enum LlmGatewayTier { public String requestHeader() { return requestHeader; } + + /** Returns the stable Java Chat workload label for this request tier. */ + public String workloadHeader(String operation) { + if (operation == null || operation.isBlank()) { + throw new IllegalArgumentException("operation must be non-blank"); + } + return "java-chat:" + operation + ":" + (this == LIVE ? "live" : "batch"); + } } diff --git a/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java b/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java index 148d3f79..b8d86755 100644 --- a/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java +++ b/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java @@ -445,6 +445,8 @@ private OpenAIClient createClient(String apiKey, String baseUrl) { // SDK retry sleeps interfere with reactive cancellation. .maxRetries(0) .putHeader(LlmGatewayTier.REQUEST_TIER_HEADER, resolvedLlmGatewayTier()) + .putHeader( + LlmGatewayTier.REQUEST_WORKLOAD_HEADER, LlmGatewayTier.LIVE.workloadHeader("chat-completions")) .build(); } diff --git a/src/main/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClient.java b/src/main/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClient.java index 47e6d71b..78c24ff7 100644 --- a/src/main/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClient.java +++ b/src/main/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClient.java @@ -737,6 +737,7 @@ private static OpenAIClient createTieredClient(String apiKey, String baseUrl, Ll .apiKey(apiKey) .baseUrl(baseUrl) .putHeader(LlmGatewayTier.REQUEST_TIER_HEADER, requestTier.requestHeader()) + .putHeader(LlmGatewayTier.REQUEST_WORKLOAD_HEADER, requestTier.workloadHeader("embeddings")) .maxRetries(0); return clientBuilder.build(); } diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClientWireTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClientWireTest.java index f689e7ba..644a4baf 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClientWireTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAiCompatibleEmbeddingClientWireTest.java @@ -78,6 +78,7 @@ private void handleEmbeddingRequest(HttpExchange exchange, List Date: Wed, 14 Oct 2026 16:28:10 -0700 Subject: [PATCH 10/13] style(llm): apply gateway workload formatting Record the formatter output from the workload attribution patch as a separate commit so the source and wire test stay clean. Agent-Session: 01a13c59-f542-7652-ad75-21c177f6e2fd From 2a740a333a145c6eb875c6e1080cb200321bf29a Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 14 Oct 2026 16:57:14 -0700 Subject: [PATCH 11/13] fix(llm): preserve JSON output budgets and workload tiers Disable reasoning for capped JSON reranking and enrichment while keeping the tested low effort for chat. Gateway records 8398799 and 8398792 confirm explicit none is accepted under the 512-token cap. Derive chat workload labels from the effective capacity tier and cover the actual SDK HTTP request for live and batch routing. Remove the stale GPT-5.4 guard exemption and align effort documentation with application defaults. This corrects the JSON-budget and attribution defects reported by Codex. Agent-Session: 01a13c59-f542-7652-ad75-21c177f6e2fd --- .../java-no-obsolete-gpt5-chat-model-test.yml | 6 +- .../java-no-obsolete-gpt5-chat-model.yml | 5 +- docs/getting-started.md | 2 +- .../service/OpenAIStreamingService.java | 114 ++++-------------- .../service/OpenAiRequestFactory.java | 8 +- src/main/resources/application-dev.properties | 4 +- .../service/OpenAICompletionStatusTest.java | 80 +++++++++++- .../service/OpenAiRequestFactoryTest.java | 9 +- 8 files changed, 119 insertions(+), 109 deletions(-) diff --git a/config/ast-grep-tests/java-no-obsolete-gpt5-chat-model-test.yml b/config/ast-grep-tests/java-no-obsolete-gpt5-chat-model-test.yml index 5fbccfe9..ec513861 100644 --- a/config/ast-grep-tests/java-no-obsolete-gpt5-chat-model-test.yml +++ b/config/ast-grep-tests/java-no-obsolete-gpt5-chat-model-test.yml @@ -2,13 +2,17 @@ id: java-no-obsolete-gpt5-chat-model valid: - | class ChatModelConfig { - private static final String MODEL = "gpt-5.4"; + private static final String MODEL = "claude-haiku-5-5"; } - | class OtherModelConfig { private static final String MODEL = "o3-mini"; } invalid: + - | + class ObsoleteChatModelConfig { + private static final String MODEL = "gpt-5.4"; + } - | class ProviderQualifiedChatModelConfig { private static final String MODEL = "provider/gpt-5.4"; diff --git a/config/ast-grep/java-no-obsolete-gpt5-chat-model.yml b/config/ast-grep/java-no-obsolete-gpt5-chat-model.yml index bfc67b44..d2eeb17d 100644 --- a/config/ast-grep/java-no-obsolete-gpt5-chat-model.yml +++ b/config/ast-grep/java-no-obsolete-gpt5-chat-model.yml @@ -12,7 +12,4 @@ files: - 'src/main/java/**' rule: kind: string_literal - all: - - regex: '^"(?i:(?:[^"/:]+[/:])*gpt-5[^"]*)"$' - - not: - regex: '^"gpt-5\.4"$' + regex: '^"(?i:(?:[^"/:]+[/:])*gpt-5[^"]*)"$' diff --git a/docs/getting-started.md b/docs/getting-started.md index dadbe254..374c6ef7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -22,7 +22,7 @@ Configure the shared gateway `OPENAI_BASE_URL` and `OPENAI_API_KEY` used by embe Set `OPENAI_API_KEY`, `OPENAI_BASE_URL`, and a non-blank gateway `OPENAI_MODEL` alias (the default is `claude-haiku-5-5`). Java Chat uses the shared gateway only and does not dispatch failed requests to another provider. -Leave `app.llm.reasoning-effort` unset to preserve the gateway/model default; set one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` only for an explicit override. +Chat uses the tested `app.llm.reasoning-effort=low` setting; supported overrides are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. 3) Start the fresh generation-specific Qdrant 1.18.3 Compose project (optional but required for full RAG): diff --git a/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java b/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java index b8d86755..bb0db4fa 100644 --- a/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java +++ b/src/main/java/com/williamcallahan/javachat/service/OpenAIStreamingService.java @@ -39,13 +39,7 @@ import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; -/** - * Streams and completes chat responses using the configured OpenAI-compatible gateway. - * - *

This service orchestrates the OpenAI SDK transport and terminal failure - * reporting while delegating admission and request construction to focused - * collaborators.

- */ +/** Owns shared-gateway SDK transport and terminal failure reporting. */ @Service public class OpenAIStreamingService { private static final Logger log = LoggerFactory.getLogger(OpenAIStreamingService.class); @@ -74,14 +68,7 @@ public class OpenAIStreamingService { @Value("${LLM_GATEWAY_TIER:production-z}") private String llmGatewayTier; - /** - * Creates a streaming service with explicit dependencies for routing and payload construction. - * - * @param rateLimitService provider rate-limit state tracker - * @param requestFactory request payload and truncation builder - * @param providerRoutingService configured-provider selection and failure classifier - * @param streamingFailureReporter terminal provider-failure boundary - */ + /** Keeps provider admission, request construction, and failure reporting independently owned. */ public OpenAIStreamingService( RateLimitService rateLimitService, OpenAiRequestFactory requestFactory, @@ -125,13 +112,7 @@ public void shutdown() { closeClientSafely(openAiClient, RateLimitService.ApiProvider.OPENAI.getName()); } - /** - * Streams a response from the configured provider. - * - * @param structuredPrompt typed prompt segments - * @param temperature response temperature - * @return stream result including text chunks and the selected provider - */ + /** Retains selected-provider and context-document provenance alongside the response stream. */ public Mono streamResponse(StructuredPrompt structuredPrompt, double temperature) { log.debug("Starting OpenAI stream with structured prompt"); @@ -155,52 +136,24 @@ public Mono streamResponse(StructuredPrompt structuredPrompt, d .subscribeOn(Schedulers.boundedElastic()); } - /** - * Sends a non-streaming completion request to the configured provider. - * - * @param prompt completion prompt - * @param temperature response temperature - * @return completion text from the selected provider - */ + /** Uses the configured output budget for a non-streaming text completion. */ public Mono complete(String prompt, double temperature) { return executeCompletion(prompt, temperature, CompletionRequestConfiguration.defaultText()); } - /** - * Sends a non-streaming completion request with an explicit output budget. - * - * @param prompt completion prompt - * @param temperature response temperature - * @param maximumOutputTokens maximum output tokens needed by this caller - * @return completion text from the selected provider - */ + /** Preserves the caller's output budget for a non-streaming text completion. */ public Mono complete(String prompt, double temperature, int maximumOutputTokens) { return Mono.defer(() -> executeCompletion( prompt, temperature, CompletionRequestConfiguration.boundedText(maximumOutputTokens))); } - /** - * Sends a non-streaming completion request that requires a JSON object response. - * - * @param prompt completion prompt - * @param temperature response temperature - * @param maximumOutputTokens maximum output tokens needed by this caller - * @return completion text from the selected provider honoring the JSON contract - */ + /** Requires a JSON object within the caller's output budget and the default request timeout. */ public Mono completeJsonObject(String prompt, double temperature, int maximumOutputTokens) { return completeJsonObject( prompt, temperature, maximumOutputTokens, CompletionRequestConfiguration.defaultRequestTimeout()); } - /** - * Sends a non-streaming completion request that requires a JSON object response within a caller-owned timeout. - * - * @param prompt completion prompt - * @param temperature response temperature - * @param maximumOutputTokens maximum output tokens needed by this caller - * @param requestTimeout whole-request timeout owned by this caller - * @return completion text from the selected provider honoring the JSON contract - */ + /** Requires a JSON object within both the caller's output budget and whole-request timeout. */ public Mono completeJsonObject( String prompt, double temperature, int maximumOutputTokens, Duration requestTimeout) { return Mono.defer(() -> executeCompletion( @@ -213,7 +166,9 @@ private Mono executeCompletion( RateLimitService.ApiProvider configuredProvider = providerRoutingService.configuredProvider(); ResponseCreateParams requestParameters = buildCompletionRequest(prompt, temperature, configuration); RequestOptions requestOptions = RequestOptions.builder() - .timeout(completeTimeout(configuration.requestTimeout())) + .timeout(Timeout.builder() + .request(configuration.requestTimeout()) + .build()) .build(); OpenAiProviderCandidate providerAdmission = requireConfiguredProviderAdmission(); @@ -255,12 +210,7 @@ private ResponseCreateParams buildCompletionRequest( prompt, temperature, configuration.maximumOutputTokens().orElseThrow()); } - /** - * Returns whether a streaming failure is transient at the request boundary. - * - * @param throwable streaming failure - * @return true when the failure category is transient - */ + /** Classifies the upstream failure even when the reporting boundary has wrapped it. */ public boolean isRecoverableStreamingFailure(Throwable throwable) { Throwable upstreamFailure = ReportedStreamingFailure.findInCauseChain(throwable) .map(ReportedStreamingFailure::upstreamFailure) @@ -300,8 +250,9 @@ private Flux executeStreamingWithConfiguredProvider( private Flux executeStreamingRequest( OpenAIClient client, ResponseCreateParams requestParameters, RateLimitService.ApiProvider activeProvider) { - RequestOptions requestOptions = - RequestOptions.builder().timeout(streamingTimeout()).build(); + RequestOptions requestOptions = RequestOptions.builder() + .timeout(Timeout.builder().request(streamingRequestTimeout()).build()) + .build(); return Flux.defer(() -> { AtomicBoolean emittedVisibleText = new AtomicBoolean(false); @@ -338,14 +289,7 @@ private Flux executeStreamingRequest( }); } - /** - * Errors the stream when no visible text arrives within the streaming request budget. - * - *

The watchdog shares {@link #streamingRequestTimeout()} instead of owning a tighter - * hardcoded budget: a healthy response whose first visible token lands late inside the - * request budget (for example with non-{@code none} reasoning effort) must survive, while a - * hung connection that never yields visible text still fails once the budget elapses.

- */ + /** Gives reasoning the full request budget while rejecting streams that never yield visible text. */ Flux enforceVisibleOutputDeadline(Flux responseTextChunks) { return responseTextChunks.publish(sharedTextChunks -> { Flux visibleOutputWatchdog = sharedTextChunks @@ -421,10 +365,6 @@ private static Throwable unwrapCompletionFailure(Throwable completionFailure) { return completionFailure; } - private Timeout streamingTimeout() { - return Timeout.builder().request(streamingRequestTimeout()).build(); - } - /** * Resolves the whole-request streaming budget shared by the SDK request timeout and the * visible-output watchdog, so the watchdog can never be tighter than the request budget. @@ -433,20 +373,18 @@ private Duration streamingRequestTimeout() { return Duration.ofSeconds(Math.max(1, streamingRequestTimeoutSeconds)); } - private Timeout completeTimeout(Duration requestTimeout) { - return Timeout.builder().request(requestTimeout).build(); - } - private OpenAIClient createClient(String apiKey, String baseUrl) { + String requestTier = resolvedLlmGatewayTier(); + LlmGatewayTier workloadTier = + LlmGatewayTier.BATCH.requestHeader().equals(requestTier) ? LlmGatewayTier.BATCH : LlmGatewayTier.LIVE; return OpenAIOkHttpClient.builder() .apiKey(apiKey) .baseUrl(OpenAiSdkUrlNormalizer.normalize(baseUrl)) // Caller-owned request timeouts and provider routing own failure handling. // SDK retry sleeps interfere with reactive cancellation. .maxRetries(0) - .putHeader(LlmGatewayTier.REQUEST_TIER_HEADER, resolvedLlmGatewayTier()) - .putHeader( - LlmGatewayTier.REQUEST_WORKLOAD_HEADER, LlmGatewayTier.LIVE.workloadHeader("chat-completions")) + .putHeader(LlmGatewayTier.REQUEST_TIER_HEADER, requestTier) + .putHeader(LlmGatewayTier.REQUEST_WORKLOAD_HEADER, workloadTier.workloadHeader("chat-completions")) .build(); } @@ -539,20 +477,12 @@ private String extractTextFromCompletedResponse(Response providerResponse) { return visibleCompletion; } - /** - * Check if the OpenAI streaming service is properly configured and available. - * - * @return true when the configured provider client is initialized - */ + /** Requires both successful initialization and an available configured-provider client. */ public boolean isAvailable() { return isAvailable && providerRoutingService.hasConfiguredProviderClient(openAiClient); } - /** - * Returns whether chat work can begin without entering a known provider outage window. - * - * @return true when the configured provider is initialized and currently eligible - */ + /** Rejects chat admission during a known outage of the configured provider. */ public boolean canAttemptRequest() { return isAvailable && providerRoutingService.canAttemptConfiguredProviderRequest(openAiClient); } diff --git a/src/main/java/com/williamcallahan/javachat/service/OpenAiRequestFactory.java b/src/main/java/com/williamcallahan/javachat/service/OpenAiRequestFactory.java index a54bdd5e..b00a3ffa 100644 --- a/src/main/java/com/williamcallahan/javachat/service/OpenAiRequestFactory.java +++ b/src/main/java/com/williamcallahan/javachat/service/OpenAiRequestFactory.java @@ -237,10 +237,10 @@ private ResponseCreateParams configureResponseParams( if (Double.isFinite(temperature)) { builder.temperature(temperature); } - // The reasoning-effort override is a chat-generation knob. JSON-object completions own - // small output budgets (reranker/enrichment) whose visible JSON answer reasoning tokens - // would starve, so those calls inherit the gateway/model default instead. - if (!requireJsonObject) { + // Reranker/enrichment caps reserve their entire budget for visible JSON. + if (requireJsonObject) { + builder.reasoning(Reasoning.builder().effort(ReasoningEffort.NONE).build()); + } else { reasoningEffort.ifPresent(effort -> builder.reasoning(Reasoning.builder().effort(effort).build())); } diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 508c50bb..f767cac9 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -11,9 +11,9 @@ app.clicky.enabled=false # OpenAI Java SDK streaming configuration (OpenAIStreamingService) # Base URLs are used by the SDK directly (not Spring AI). # OPENAI_BASE_URL must identify the shared gateway. -# Dev inherits the absent app.llm.reasoning-effort default; set it only for an explicit override. +# Dev inherits the tested low app.llm.reasoning-effort setting. # Streaming timeout: OPENAI_STREAMING_REQUEST_TIMEOUT_SECONDS (default: 90). -# Java Chat enforces a 20-second visible-output deadline; the gateway owns lower-level transport policy. +# The visible-output deadline shares the streaming request budget; the gateway owns transport policy. # Java Chat sends chat to the configured gateway and does not implement client-side provider fallback. # DevTools Configuration for Live Refresh diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAICompletionStatusTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAICompletionStatusTest.java index eeaa2c53..6b615011 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAICompletionStatusTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAICompletionStatusTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -10,6 +11,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.openai.client.OpenAIClient; import com.openai.client.OpenAIClientAsync; import com.openai.core.RequestOptions; @@ -22,13 +25,22 @@ import com.openai.models.responses.ResponseOutputText; import com.openai.models.responses.ResponseStatus; import com.openai.services.async.ResponseServiceAsync; +import com.sun.net.httpserver.HttpServer; import com.williamcallahan.javachat.adapters.out.llm.openai.OpenAiStreamingFailureReporter; import com.williamcallahan.javachat.application.prompt.PromptTruncator; import com.williamcallahan.javachat.config.AppProperties; +import com.williamcallahan.javachat.config.ModelConfiguration; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.test.util.ReflectionTestUtils; import reactor.test.StepVerifier; @@ -40,6 +52,69 @@ class OpenAICompletionStatusTest { private static final String VISIBLE_COMPLETION = "Visible completion"; private static final String PROVIDER_REFUSAL = "I cannot help with that request."; + @ParameterizedTest + @ValueSource(strings = {"production-z", "production-a", "batch"}) + void jsonCompletionSendsMatchingWorkloadTierAndNoReasoning(String requestTier) throws IOException { + AtomicReference capturedRequest = new AtomicReference<>(); + HttpServer gatewayServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + gatewayServer.createContext("/v1/responses", exchange -> { + JsonNode requestBody = new ObjectMapper().readTree(exchange.getRequestBody()); + capturedRequest.set(new CapturedCompletionRequest( + exchange.getRequestHeaders().getFirst("X-Tier"), + exchange.getRequestHeaders().getFirst("X-User-ID"), + requestBody)); + byte[] responseBody = """ + {"id":"resp_wire","object":"response","created_at":0, + "model":"wire-test-model","status":"completed", + "output":[{"id":"msg_wire","type":"message","role":"assistant", + "status":"completed","content":[{"type":"output_text", + "text":"{\\"ok\\":true}","annotations":[]}]}]} + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, responseBody.length); + try (var responseStream = exchange.getResponseBody()) { + responseStream.write(responseBody); + } + }); + RateLimitService rateLimitService = mock(RateLimitService.class); + when(rateLimitService.tryReserveRequest(RateLimitService.ApiProvider.OPENAI)) + .thenReturn(true); + OpenAIStreamingService streamingService = configuredStreamingService(rateLimitService); + ReflectionTestUtils.setField(streamingService, "openaiApiKey", "wire-test-api-key"); + ReflectionTestUtils.setField(streamingService, "openaiBaseUrl", "https://api.llm-gateway.iocloudhost.net/v1"); + ReflectionTestUtils.setField(streamingService, "llmGatewayTier", requestTier); + gatewayServer.start(); + try { + streamingService.initializeClient(); + OpenAIClient configuredClient = assertInstanceOf( + OpenAIClient.class, ReflectionTestUtils.getField(streamingService, "openAiClient")); + // Redirect only the test transport while retaining the application's configured headers. + ReflectionTestUtils.setField( + streamingService, + "openAiClient", + configuredClient.withOptions(options -> options.baseUrl( + "http://127.0.0.1:" + gatewayServer.getAddress().getPort() + "/v1"))); + assertEquals( + "{\"ok\":true}", + streamingService + .completeJsonObject("Return a JSON object with ok true", 0.0, 512) + .block(Duration.ofSeconds(10))); + } finally { + streamingService.shutdown(); + gatewayServer.stop(0); + } + CapturedCompletionRequest gatewayRequest = capturedRequest.get(); + assertNotNull(gatewayRequest); + assertEquals(requestTier, gatewayRequest.tierHeader()); + assertEquals( + "java-chat:chat-completions:" + (requestTier.equals("batch") ? "batch" : "live"), + gatewayRequest.workloadHeader()); + assertEquals(512, gatewayRequest.requestBody().path("max_output_tokens").asInt()); + assertEquals( + "none", + gatewayRequest.requestBody().path("reasoning").path("effort").asText()); + } + @Test void completionRecordsSuccessOnlyAfterCompletedStatus() { RateLimitService rateLimitService = mock(RateLimitService.class); @@ -286,7 +361,8 @@ private static AppProperties configuredLlmProperties() { } private static OpenAiRequestFactory testRequestFactory() { - return new OpenAiRequestFactory(new Chunker(), new PromptTruncator(), "gpt-5.4", configuredLlmProperties()); + return new OpenAiRequestFactory( + new Chunker(), new PromptTruncator(), ModelConfiguration.DEFAULT_MODEL, configuredLlmProperties()); } private static ResponseServiceAsync mockAsyncResponseService(OpenAIClient openAiClient) { @@ -297,6 +373,8 @@ private static ResponseServiceAsync mockAsyncResponseService(OpenAIClient openAi return responseService; } + private record CapturedCompletionRequest(String tierHeader, String workloadHeader, JsonNode requestBody) {} + private record CompletionFailureScenario( Response providerCompletion, OpenAiResponseException.TerminalReason expectedTerminalReason, diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java index b65ddc9b..f753e1a2 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAiRequestFactoryTest.java @@ -141,7 +141,7 @@ void prepareStreamingRequestAppliesTheConfiguredReasoningEffort() { } @Test - void jsonCompletionOmitsTheConfiguredReasoningEffort() { + void jsonCompletionReservesItsOutputBudgetForVisibleJson() { OpenAiRequestFactory requestFactory = createRequestFactory("max"); ResponseCreateParams jsonCompletionParams = @@ -149,9 +149,10 @@ void jsonCompletionOmitsTheConfiguredReasoningEffort() { assertTrue( jsonCompletionParams.text().orElseThrow().format().orElseThrow().isJsonObject()); - assertTrue( - jsonCompletionParams.reasoning().isEmpty(), - "JSON completions inherit the gateway default so reasoning tokens cannot starve the output budget"); + assertEquals(128L, jsonCompletionParams.maxOutputTokens().orElseThrow()); + assertEquals( + ReasoningEffort.NONE, + jsonCompletionParams.reasoning().orElseThrow().effort().orElseThrow()); } @Test From 396452d7a9b910652fda1f4f3f50737408dbed4e Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 14 Oct 2026 17:17:45 -0700 Subject: [PATCH 12/13] test(streaming): verify timeout policy at the SDK request boundary Exercise streaming dispatch and inspect the request options sent to the SDK instead of invoking a private timeout builder by reflection. This preserves the whole-request/read deadline contract across transport simplifications. This fixes the hook failure caused by the timeout cleanup reported by Codex. Agent-Session: 01a13c59-f542-7652-ad75-21c177f6e2fd --- .../OpenAIStreamingTimeoutContractTest.java | 64 ++++++++++++++++--- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java index 715f2fa2..58031685 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java @@ -1,11 +1,24 @@ package com.williamcallahan.javachat.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; -import com.openai.core.Timeout; -import com.williamcallahan.javachat.application.streaming.StreamingFailureReporter; +import com.openai.client.OpenAIClient; +import com.openai.client.OpenAIClientAsync; +import com.openai.core.RequestOptions; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.services.async.ResponseServiceAsync; +import com.williamcallahan.javachat.adapters.out.llm.openai.OpenAiStreamingFailureException; +import com.williamcallahan.javachat.adapters.out.llm.openai.OpenAiStreamingFailureReporter; +import com.williamcallahan.javachat.config.ModelConfiguration; +import com.williamcallahan.javachat.domain.prompt.StructuredPrompt; import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -14,16 +27,51 @@ class OpenAIStreamingTimeoutContractTest { @Test void streamingReadDeadlineInheritsWholeRequestDeadline() { + OpenAIClient gatewayClient = mock(OpenAIClient.class); + StructuredPrompt structuredPrompt = StructuredPrompt.fromRawPrompt("Confirm the timeout budget", 6); + OpenAiRequestFactory requestFactory = mock(OpenAiRequestFactory.class); + when(requestFactory.prepareStreamingRequest(structuredPrompt, 0.4)) + .thenReturn(new OpenAiPreparedRequest( + ResponseCreateParams.builder() + .model(ModelConfiguration.DEFAULT_MODEL) + .input("Timeout proof") + .build(), + ModelConfiguration.DEFAULT_MODEL, + structuredPrompt)); + OpenAiProviderRoutingService providerRoutingService = mock(OpenAiProviderRoutingService.class); + when(providerRoutingService.hasConfiguredProviderClient(gatewayClient)).thenReturn(true); + when(providerRoutingService.configuredProvider()).thenReturn(RateLimitService.ApiProvider.OPENAI); + when(providerRoutingService.admitConfiguredProviderRequest(gatewayClient)) + .thenReturn( + Optional.of(new OpenAiProviderCandidate(gatewayClient, RateLimitService.ApiProvider.OPENAI))); OpenAIStreamingService streamingService = new OpenAIStreamingService( mock(RateLimitService.class), - mock(OpenAiRequestFactory.class), - mock(OpenAiProviderRoutingService.class), - mock(StreamingFailureReporter.class)); + requestFactory, + providerRoutingService, + new OpenAiStreamingFailureReporter()); + OpenAIClientAsync asyncClient = mock(OpenAIClientAsync.class); + ResponseServiceAsync responseService = mock(ResponseServiceAsync.class); + when(gatewayClient.async()).thenReturn(asyncClient); + when(asyncClient.responses()).thenReturn(responseService); + AtomicReference capturedRequest = new AtomicReference<>(); + when(responseService.createStreaming(any(ResponseCreateParams.class), any(RequestOptions.class))) + .thenAnswer(invocation -> { + capturedRequest.set(invocation.getArgument(1, RequestOptions.class)); + throw new IllegalStateException("Stop the timeout proof before network access"); + }); + ReflectionTestUtils.setField(streamingService, "openAiClient", gatewayClient); ReflectionTestUtils.setField(streamingService, "streamingRequestTimeoutSeconds", 600L); - Timeout streamingTimeout = ReflectionTestUtils.invokeMethod(streamingService, "streamingTimeout"); + assertThrows(OpenAiStreamingFailureException.class, () -> streamingService + .streamResponse(structuredPrompt, 0.4) + .flatMapMany(StreamingResult::textChunks) + .blockLast(Duration.ofSeconds(10))); - assertEquals(Duration.ofSeconds(600), streamingTimeout.request()); - assertEquals(streamingTimeout.request(), streamingTimeout.read()); + RequestOptions gatewayRequest = capturedRequest.get(); + assertNotNull(gatewayRequest); + assertEquals(Duration.ofSeconds(600), gatewayRequest.getTimeout().request()); + assertEquals( + gatewayRequest.getTimeout().request(), + gatewayRequest.getTimeout().read()); } } From 7989641c33b554920f68549c2d592d4dbaf44f22 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Wed, 14 Oct 2026 17:42:53 -0700 Subject: [PATCH 13/13] test(streaming): require timeout options before checking deadline policy RequestOptions.getTimeout is nullable at the SDK boundary. Require its presence before checking the request/read deadline contract, fixing the SpotBugs failure reported by Codex. Agent-Session: 01a13c59-f542-7652-ad75-21c177f6e2fd --- .../service/OpenAIStreamingTimeoutContractTest.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java index 58031685..f8ab56df 100644 --- a/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java +++ b/src/test/java/com/williamcallahan/javachat/service/OpenAIStreamingTimeoutContractTest.java @@ -10,6 +10,7 @@ import com.openai.client.OpenAIClient; import com.openai.client.OpenAIClientAsync; import com.openai.core.RequestOptions; +import com.openai.core.Timeout; import com.openai.models.responses.ResponseCreateParams; import com.openai.services.async.ResponseServiceAsync; import com.williamcallahan.javachat.adapters.out.llm.openai.OpenAiStreamingFailureException; @@ -17,6 +18,7 @@ import com.williamcallahan.javachat.config.ModelConfiguration; import com.williamcallahan.javachat.domain.prompt.StructuredPrompt; import java.time.Duration; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -69,9 +71,9 @@ void streamingReadDeadlineInheritsWholeRequestDeadline() { RequestOptions gatewayRequest = capturedRequest.get(); assertNotNull(gatewayRequest); - assertEquals(Duration.ofSeconds(600), gatewayRequest.getTimeout().request()); - assertEquals( - gatewayRequest.getTimeout().request(), - gatewayRequest.getTimeout().read()); + Timeout requestTimeout = + Objects.requireNonNull(gatewayRequest.getTimeout(), "Streaming requests must specify their timeout"); + assertEquals(Duration.ofSeconds(600), requestTimeout.request()); + assertEquals(requestTimeout.request(), requestTimeout.read()); } }