Skip to content

feat(openai): OpenAI-compatible API adapter for Open WebUI and SDK clients - #614

Merged
ginccc merged 30 commits into
mainfrom
feat/openai-api-adapter
Jul 29, 2026
Merged

feat(openai): OpenAI-compatible API adapter for Open WebUI and SDK clients#614
ginccc merged 30 commits into
mainfrom
feat/openai-api-adapter

Conversation

@ginccc

@ginccc ginccc commented Jul 28, 2026

Copy link
Copy Markdown
Member

Adds a /v1 adapter presenting deployed EDDI agents as OpenAI "models", so Open WebUI, the Python openai SDK, LangChain and LiteLLM can drive an EDDI conversation without knowing about EDDI.

New package integrations/openai/, parallel to integrations/slack/. Purely additive outside the adapter: application.properties gains lines only, AGENTS.md gains one roadmap row, and no existing Java file is modified.

Full guide: docs/open-webui-integration.md. Design record and research: planning/openai-api-adapter-plan.md.


What it does

  • GET /v1/models, GET /v1/models/{id}, POST /v1/chat/completions (sync JSON + SSE streaming)
  • Per-chat conversation isolation keyed on X-OpenWebUI-Chat-Id — each Open WebUI chat window gets its own EDDI conversation, memory and all
  • Attachments: image_url, file (inline PDFs/documents) and input_audio, all mapped to attachment_N context so EDDI's existing forwarder does the real work — capability gating, byte caps, PDF text extraction, SSRF-guarded fetching
  • HITL-aware: pauses surface as normal 200 assistant messages, never as HTTP errors
  • Stateless requests via a :stateless model suffix or a stateless body field
  • Everything downstream is untouched — behavior rules, tools, RAG, cascading, windowing, user memory, cost tracking, audit ledger, GDPR Art. 18 — because every message goes through IConversationService

Disabled by default (eddi.openai-compat.enabled=false).

Four earlier plan drafts rested on false premises

Each was verified against source before implementing; the corrections shaped the design. Recorded in the changelog with evidence so they don't get re-litigated:

Claim Reality
Route sync vs. streaming on the Accept header openai-python hardcodes Accept: application/json and never varies it by stream; Open WebUI sends no Accept header at all. → one method, dispatching on the stream body field.
Open WebUI PR #27174 maps chat_id → the user field It's an issue, closed as not planned. payload['user'] is set only for pipeline models and is an object, not a chat id. → key on X-OpenWebUI-Chat-Id (#15813).
Inject IRestAgentAdministration for /v1/models Type-level @RolesAllowed({"eddi-admin","eddi-editor"}), enforced by a CDI interceptor → 403 for every ordinary caller. → read the stores directly. REST facades are the authorization boundary.
"OIDC first, fall back to static key" No mechanism existed. /,/*authenticated captures /v1/*, so an sk-… bearer is rejected by OIDC before the adapter runs. → explicit /v1/* permission entry + two auth modes + startup guard.

Two components from the plan were cut: UtilityAgentProvisioner (unauthenticated caller creating and deploying an agent, consuming tenant quota — replaced by :stateless), and the new-chat heuristic (unreliable, destructive, and self-contradictory with the plan's own recommended Open WebUI filter).

Security

  • Default off; startup fails on enabled + unauthenticated + authorization.enabled=true, naming the three fixes
  • Two modes: shared API key (constant-time compare) or Quarkus OIDC
  • A caller whose identity cannot be resolved is refused, not served — serving it would merge every such caller into one shared conversation and one shared memory
  • trust-user-headers is a documented delegation, flag-gated: a leaked key permits impersonation. Called out in the guide as a warning, not a footnote.

Testing

143 unit tests, 0 failures; clean compile and Checkstyle green.

Mutation-checked rather than assumed — reverting each of these makes its tests fail: the model-ambiguity guard, the identity refusal, the HITL sentinel distinction, the stateless config gate, audio MIME mapping, filename-over-generic-type, token/snapshot reconciliation, and null-message handling.

A self-review pass after the feature was complete found six defects, all fixed and covered (see 5b8c471). The most serious: a semaphore permit leak on the streaming path that would have made the adapter return 429 permanently after enough client disconnects, with nothing in the logs to explain it.

Known gap — please review with this in mind

No test exercises the endpoint over real HTTP. The load-bearing assumption — that @Produces({APPLICATION_JSON, SERVER_SENT_EVENTS}) + StreamingOutput yields text/event-stream when stream:true — is verified by reasoning only. Socket-binding tests can't run in the dev sandbox, so this needs a @QuarkusTest in CI and is the first thing worth covering.

Other stated gaps: usage/token counts omitted (not faked as zeros); quick replies and inputField outputs dropped; tool_calls never returned (would double-execute); /v1/embeddings not implemented.

Follow-ups, deliberately not in this PR

  • SlackHitlSupport.extractSlackResponseText duplicates the newer shared ConversationOutputExtractor, which handles more formats — worth deduplicating
  • Whether :stateless should replay client history to become genuinely OpenAI-compatible for history-managing clients

Summary by CodeRabbit

  • New Features
    • Added an OpenAI-compatible /v1 adapter for GET /v1/models and POST /v1/chat/completions (streaming SSE and non-streaming), exposing deployed agents as models with optional stateless variants, multimodal attachments, and HITL-aware pause handling.
    • Supports configurable auth/identity mapping, per-request concurrency limits, and model-list caching.
  • Documentation
    • Added an Open WebUI integration guide and demo setup; updated changelog and adapter reference docs.
  • Bug Fixes
    • Improved OpenAI wire-format correctness, structured output rendering (including quick replies/input fields), and token-usage reporting when requested.
  • Tests
    • Added extensive unit and integration coverage for models, auth, request/response mapping, and SSE framing.
  • Chores
    • Added runnable demo containers/build setup and updated secret-scan suppressions.

ginccc added 10 commits July 27, 2026 22:20
First phase of the /v1 OpenAI-compatible adapter (Open WebUI integration).
Wire-format records plus the eddi.openai-compat.* configuration surface; no
behaviour yet.

Two shapes are deliberately loose rather than strict:
- ChatCompletionRequest.user is a JsonNode, not a String. The OpenAI spec says
  string, but Open WebUI sends an object {name,id,email,role} for pipeline
  models, which would fail a String binding.
- ChatMessage.content is a JsonNode. It is polymorphic in the spec (string for
  text, array for multimodal); a JsonNode handles both without a custom
  deserializer.
Unknown request fields are ignored, not rejected: clients always send
temperature/max_tokens/tools, and those belong to the agent's langchain.json.

The /v1 permission entry is required, not optional. The catch-all
authenticated policy on /,/* would otherwise capture /v1/* and reject the
shared-API-key bearer token at the OIDC layer, before the adapter's own filter
could run.

Adapter defaults to disabled. Also adds the full implementation plan.

Refs planning/openai-api-adapter-plan.md
…ication

Model catalogue, resolution, the /v1 REST surface, and the two auth pieces.
Chat completions land in the next commits.

Model ids are <slug>-<last 6 of agentId>, not the bare slug: descriptor names
are not unique, so a bare slug would make resolution non-deterministic the
moment two agents are both called "Support". Name and slug lookups are still
accepted for convenience but only when they match exactly one agent -- an
ambiguous match raises rather than guesses, because silently routing a
conversation to the wrong agent looks like an agent bug, not a routing bug.

Slugging folds accents (NFD + strip combining marks) instead of dropping them.
Without that, "Ubersicht" slugged to "bersicht" and every non-ASCII agent
name became unrecognisable in the model dropdown. Caught by its own test.

The catalogue is read from IAgentFactory + IDocumentDescriptorStore, NOT from
IRestAgentAdministration. That interface carries a type-level
@RolesAllowed({"eddi-admin","eddi-editor"}) which Quarkus enforces via a CDI
interceptor, so injecting it here would 403 for every ordinary caller. REST
facades are the authorization boundary; a public surface must not reach around
one.

Authentication has two modes because Open WebUI sends an opaque sk-... secret
that the OIDC mechanism rejects as a malformed JWT before application code
runs. Under http-policy=permit the filter enforces the shared key itself
(constant-time compare); under 'authenticated' Quarkus OIDC has already run and
the filter only reads the identity. A caller whose identity cannot be resolved
is refused rather than served, since serving it would merge every such caller
into one shared conversation and therefore one shared memory.
OpenAiStartupGuard refuses to boot on enabled + unauthenticated + authorization
on, naming the three ways to fix it.

Errors use the OpenAI {"error":{...}} envelope throughout; the mapper is scoped
to one exception type so it cannot alter the rest of EDDI's REST behaviour.

Tests: 43. Mutation-checked -- reverting the ambiguity guard and the identity
refusal each makes the relevant tests fail.
Only the last user message is carried across. The client resends the whole
conversation every turn because the OpenAI protocol is stateless; EDDI keeps
its own memory, so replaying that history would double every turn.

The last system message becomes an openai_system_message context entry rather
than overriding the agent's configured prompt. A client that could rewrite the
system prompt would make the agent non-portable, so agent designers opt in via
{context.openai_system_message}. Open WebUI's RAG chunks arrive here too.

Images map to attachment_N context entries in the shape
AttachmentContextExtractor already documents, so they flow through the existing
forwarder with its vision gating, byte caps and SSRF-guarded fetching. No
adapter-side image handling at all.

Two parsing details that would otherwise bite:
- 'data:image/png,payload' is legal and contains no ';'. Deriving the MIME by
  scanning to ';' throws StringIndexOutOfBounds on it. Parsed comma-first
  instead, and a data URI with no comma is skipped rather than failing the turn.
- Remote URLs get a concrete MIME derived from the file extension, never
  'image/*'. A wildcard passes the forwarder's startsWith("image/") gate but is
  then handed verbatim to ImageContent.from(base64, mimeType), where providers
  reject it.

Tests: 24, driven by parsing real OpenAI JSON rather than constructing records,
so the polymorphic content/user fields go through the real binding path.
The bridge, the SSE writer, and POST /v1/chat/completions.

ONE method serves both sync and streaming, dispatching on the 'stream' field
of the body. Content negotiation cannot work here: openai-python hardcodes
Accept: application/json and never varies it by stream, and Open WebUI sends no
Accept header at all (it sniffs the response content-type instead). Two
@produces methods would route every streaming request to the JSON one. The body
is the only reliable signal, and it is what the OpenAI spec specifies.

Conversations are keyed by (channel:openai:<agentId>:<chatKey>, userId) in
IUserConversationStore, mirroring Slack. chatKey comes from X-OpenWebUI-Chat-Id
-- the header Open WebUI forwards under ENABLE_FORWARD_USER_INFO_HEADERS --
falling back to the OpenAI 'user' field. That is what gives each chat window its
own conversation; without it every window a user opens against one agent shares
a single memory.

There is deliberately NO new-chat heuristic. Inferring a restart from the
message count is unreliable (regenerate and edit-and-resend look identical to a
first message) and destructive, since ending a conversation discards its memory
irrecoverably. A new chat is a new chat key, which is a new intent.

HITL is surfaced as chat text, never as an HTTP error -- a 4xx makes clients
discard the user's message. onSkipped is split by snapshot state via sentinels
(as Slack does): AWAITING_HUMAN yields a 'reviewer must decide' message, while
busy/ended yields 429 so clients back off. A turn that completes while paused
returns its output plus the notice and the conversation id.

Streaming reconciles tokens against the snapshot: rule-based agents emit text
only at onComplete, so the snapshot text is sent only when no token arrived --
otherwise the whole reply would appear twice.

Other decisions: a conversation that ended mid-turn is retried exactly once on
a fresh one (bounded, so a broken agent cannot spin); a lost createUserConversation
race adopts the winner and ends the orphan; stateless turns always end their
conversation, including on failure; in-flight completions are bounded by a
semaphore because each holds a worker thread.

Tests: 41 more (114 total). Mutation-checked: collapsing the onSkipped sentinel
distinction makes the HITL tests fail.
docs/open-webui-integration.md covers setup, the stateful/stateless bridge,
configuration, the security model, the Open WebUI settings that matter,
supported features, error contract, troubleshooting and known gaps.

Two settings are called out as load-bearing because getting them wrong fails
quietly rather than loudly: ENABLE_FORWARD_USER_INFO_HEADERS (without it every
chat window a user opens against one agent shares a single conversation and its
memory), and the title/tag generation model (pointed at a normal EDDI model,
Open WebUI's 'write a title' prompt becomes a real turn in the user's
conversation).

The trust-user-headers and allow-anonymous trade-offs are documented as
warnings rather than buried in a table -- both are deliberate delegations with
real consequences if misread.

Changelog records the false premises the four earlier plan drafts rested on and
how each was verified, since the corrections are what shaped the design.

Plan document keeps its pre-implementation form with a short delta section for
the four things that differed in practice.
Most serious: a semaphore permit leak on the streaming path. The permit was
handed to the StreamingOutput body to release, but if that body never runs --
a client disconnecting before serialization starts, say -- it is never
reclaimed. After max-concurrent-requests such events the adapter returns 429
permanently, until restart, with nothing in the logs to explain it. The
resource now releases unconditionally and the stream body acquires its own
permit inside a single try/finally, so neither can be orphaned. A stream that
cannot get a slot degrades to an in-band busy notice, since the 200 is already
committed by then.

GET /v1/models/{id} echoed back whatever the caller typed instead of the
canonical id, so a lookup by agent name returned an id absent from
GET /v1/models -- a client round-tripping the answer would then ask for a model
that does not exist. ResolvedModel now carries requested and canonical ids
separately, plus the descriptor timestamp that was hardcoded to 0.

InterruptedException was swallowed while waiting for a turn, leaving the worker
thread looking healthy with its shutdown signal gone.

A null exception message reached the user as the literal text "null" in the
stream error path; NPEs carry no message, so this was the likely case.

hasSentContent() actually reported whether the stream had started -- true even
after a content-free finish(). Renamed hasStarted().

The eddi.openai.requests counter the plan documented was never implemented.
Added with mode/outcome tags so 'paused' (reviewers behind) and 'busy' (clients
racing) are distinguishable from real errors.

Verified rather than assumed: this project sets
enable-reflection-free-serializers=false, so @JsonProperty on record components
works and finish_reason/owned_by serialize correctly. Added OpenAiWireFormatTest
to pin it -- the non-streaming response shape had no coverage at all.

Corrected the docs rather than the code on one point: unimplemented /v1 paths
return Quarkus' plain 404, not an OpenAI envelope. A catch-all route would risk
shadowing the real endpoints for a cosmetic gain.

Tests: 119 (was 104). Mutation-checked the two new behavioural fixes -- removing
the token-reconciliation guard and bypassing the null-message handling each make
the relevant tests fail.
Adds the file and input_audio content-part types alongside image_url. All three
map to attachment_N context entries, so EDDI's existing forwarder does the real
work -- capability gating, byte caps, PDF text extraction, SSRF-guarded
fetching. The adapter still handles no media itself.

PDFs now reach PdfFileContent when the model has native document support, and
are text-extracted and inlined when it does not. Any type the text extractor
already handles (.txt, .md, .csv, .json, .xml, .html) is inlined too.

The three wire formats are inconsistent in ways that are easy to get wrong, so
each is verified against the OpenAI docs and pinned by a test:

- input_audio.data is RAW base64 with no data: prefix -- unlike every other
  binary payload in this protocol -- with the type in a separate 'format' field.
  And mp3 maps to audio/mpeg: "audio/" + format would yield audio/mp3, which is
  not a real media type and which providers reject.
- file.file_data IS a full data URI. The declared filename beats a generic
  application/octet-stream type, because clients that base64 a file without
  sniffing it send exactly that -- so contract.pdf still reaches the PDF path.
- file.file_id references the OpenAI Files API, which EDDI does not implement.
  Those parts are skipped with a warning naming the fix, rather than becoming
  empty attachments.

filename is also accepted as file_name, which appears in the wild.

The per-turn cap now counts all three types together rather than images alone.

Note this path is for the OpenAI SDK and similar clients: Open WebUI does not
send documents as file parts, it runs its own RAG over uploads and injects the
retrieved text into the system message.

Tests: 132 (was 119). Mutation-checked -- naive audio MIME concatenation and
dropping the filename-over-generic-type rule each make their tests fail.
…model suffix

The :stateless model suffix exists for a specific reason: a model name is the
only per-request dimension a UI like Open WebUI can express. Its
title-generation setting is a dropdown, so a query param, header or body field
could not be selected there at all. That makes the suffix necessary, but it
does mean behaviour is encoded in an identifier, which is a smell when it is
the ONLY way to ask.

So the same thing is now expressible as a body field for callers that can say
what they mean:

  {"model": "support-a3f9c1", "messages": [...], "stateless": true}
  client.chat.completions.create(..., extra_body={"stateless": True})

The two are OR-ed rather than letting either win. model:"x:stateless" together
with stateless:false is self-contradictory, and of the two readings, running
stateless merely loses continuity while running stateful would persist a
conversation the caller may not have wanted -- so the flags fail toward the
less surprising outcome.

expose-stateless-variants=false blocks BOTH routes (400 invalid_request_field).
Gating only the suffix would have left the kill switch circumventable by moving
the request out of the model id and into the body.

ResolvedModel.asStateless() also normalises canonicalModelId, so both routes
describe themselves identically rather than one reporting a stateful id.

Docs now also correct two things I had overstated about :stateless: it is not
"no memory at all" (user-scoped long-term memory is keyed by userId, not by
conversation, so it still loads and persists for agents that enable it), and it
is not "classic OpenAI semantics" (only the last user message is sent in both
modes, so it is genuinely single-turn rather than client-managed history).

Tests: 143 (was 132), including the first RestOpenAiAdapterTest -- a plain unit
test, no socket, so it runs locally. Mutation-checked: bypassing the config gate
makes the rejection test fail.
Documents the pattern that makes a passthrough proxy mode unnecessary. If what
you want is vaulted API keys, an audit trail, quotas and cost tracking -- not
agent logic -- a minimal LLM-only agent behind this adapter already provides
all of it and behaves like a plain model call.

Building an actual proxy would mean entering the LLM-gateway market against
LiteLLM, Portkey and Cloudflare AI Gateway with a worse product, inheriting
per-provider streaming/tool/vision passthrough maintenance for zero agent
value, and shipping a feature with neither logic nor configuration -- the
opposite of Pillar 1.

The limits are stated before the recipe, not after: single-turn only (the
adapter sends just the last user message), one agent per model (a caller cannot
route to an arbitrary 'gpt-4o'), and no caching, fallbacks, load balancing or
virtual per-user keys. If you need those, use a real gateway.

Every config shape is taken from working examples in this repo -- agent and
workflow from docs/agent-configs/agent-father, the LLM task from
docs/langchain.md, the vault reference syntax from docs/a2a-protocol.md --
rather than invented. The one deliberate departure from AGENTS.md 5.3 (a rule
with no actionmatcher on lastStep) is called out with its reason: firing on
every turn is the intent for a gateway, not an ordering bug. The recipe is
labelled as an unverified pattern, since the adapter's test suite does not
exercise it end to end.
@ginccc
ginccc requested a review from rolandpickl as a code owner July 28, 2026 13:03
Copilot AI review requested due to automatic review settings July 28, 2026 13:03
@github-actions

Copy link
Copy Markdown

⚠️ Deprecation Warning: The deny-licenses option is deprecated for possible removal in the next major release. For more information, see issue 997.

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The pull request adds a configurable /v1 OpenAI-compatible API for deployed EDDI agents, including model discovery, authentication, stateful and stateless conversations, multimodal mapping, HITL handling, synchronous responses, SSE streaming, structured output rendering, token usage reporting, tests, and a Docker/Open WebUI demo.

Changes

OpenAI-compatible API adapter

Layer / File(s) Summary
Configuration and request security
src/main/java/ai/labs/eddi/integrations/openai/OpenAiCompatConfig.java, OpenAiAuthFilter.java, OpenAiStartupGuard.java, OpenAiApiException.java, src/main/resources/application.properties
Adds adapter configuration, /v1 authorization, API-key and OIDC identity handling, startup validation, and OpenAI-style error responses.
Model catalogue and wire contracts
src/main/java/ai/labs/eddi/integrations/openai/AgentModelResolver.java, src/main/java/ai/labs/eddi/integrations/openai/model/*
Builds cached canonical and stateless model listings, resolves model identifiers, and defines OpenAI request, response, model, chunk, attachment, usage, and error payloads.
Chat request and attachment mapping
src/main/java/ai/labs/eddi/integrations/openai/OpenAiMessageMapper.java, ChatMessage.java, ContentPart.java
Maps user messages and system context into EDDI input, including text, image, file, and audio attachments with MIME inference and attachment limits.
Conversation lifecycle and output rendering
src/main/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridge.java, OpenAiOutputRenderer.java
Adds conversation reuse, stateless cleanup, stale-conversation recovery, race handling, HITL outcomes, retry behavior, snapshot rendering, structured Markdown extras, token usage extraction, and asynchronous stream callbacks.
REST endpoints and SSE responses
src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java, OpenAiSseWriter.java
Adds /v1/models, /v1/models/{modelId}, and /v1/chat/completions, with stateless routing, concurrency bounds, JSON responses, usage-aware SSE framing, flushing, and stream termination.
Validation, documentation, and runnable demo
src/test/java/ai/labs/eddi/integrations/openai/*, src/test/java/ai/labs/eddi/integration/*, docs/*, planning/*, docker-compose.openwebui.yml, src/main/docker/*, AGENTS.md, .gitleaksignore
Adds unit and HTTP-level coverage, documents setup and adapter behavior, and supplies Docker/Open WebUI demo infrastructure and secret-scan suppressions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant OpenAiAuthFilter
  participant RestOpenAiAdapter
  participant AgentModelResolver
  participant OpenAiConversationBridge
  participant OpenAiSseWriter

  Client->>OpenAiAuthFilter: Send authenticated /v1 chat request
  OpenAiAuthFilter->>RestOpenAiAdapter: Provide resolved user identity
  RestOpenAiAdapter->>AgentModelResolver: Resolve requested model
  RestOpenAiAdapter->>OpenAiConversationBridge: Prepare and execute conversation turn
  OpenAiConversationBridge->>OpenAiSseWriter: Emit streaming chunks and usage
  OpenAiSseWriter-->>Client: Return SSE frames and [DONE]
Loading

Suggested reviewers: rolandpickl, copilot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.27% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: a new OpenAI-compatible API adapter for Open WebUI and SDK clients.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/openai-api-adapter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java Dismissed
this.authorizationEnabled = authorizationEnabled;
}

void onStart(@Observes StartupEvent event) {

Copilot AI 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.

Pull request overview

Adds a new /v1 OpenAI-compatible adapter layer so Open WebUI and OpenAI-protocol SDK clients can drive EDDI conversations without knowing EDDI’s native API, while keeping all downstream processing inside IConversationService (rules, tools, RAG, HITL, memory, etc.). The adapter is disabled by default and is wired via additive configuration and a new integration package.

Changes:

  • Introduces ai.labs.eddi.integrations.openai with endpoints for /v1/models and /v1/chat/completions (JSON + SSE), plus auth, startup guard, model resolution, message mapping, and SSE framing.
  • Adds configuration and Quarkus HTTP auth-permission wiring for /v1/* to support either shared API key mode or OIDC mode.
  • Adds extensive unit test coverage for binding, wire format, SSE framing, auth behavior, model resolution, message/attachment mapping, and conversation-bridging behavior; adds end-user documentation and changelog entry.

Reviewed changes

Copilot reviewed 34 out of 34 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java New /v1 JAX-RS resource for models + chat completions (sync + SSE), concurrency bounding
src/main/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridge.java Bridges stateless OpenAI requests to stateful EDDI conversations (mapping, HITL handling, streaming)
src/main/java/ai/labs/eddi/integrations/openai/AgentModelResolver.java Lists/resolves deployed agents as OpenAI “models”, including stateless variants and ambiguity handling
src/main/java/ai/labs/eddi/integrations/openai/OpenAiMessageMapper.java Maps OpenAI messages[] (incl. multimodal parts) into EDDI InputData + attachment_N contexts
src/main/java/ai/labs/eddi/integrations/openai/OpenAiSseWriter.java Writes OpenAI-compatible SSE chunk framing to an output stream
src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java Auth + identity resolution filter for /v1/* (api-key mode or OIDC mode)
src/main/java/ai/labs/eddi/integrations/openai/OpenAiStartupGuard.java Fails startup on insecure “enabled + unauthenticated” combinations when auth is expected
src/main/java/ai/labs/eddi/integrations/openai/OpenAiCompatConfig.java Config carrier for eddi.openai-compat.*
src/main/java/ai/labs/eddi/integrations/openai/OpenAiApiException.java Exception type carrying OpenAI-style error envelope metadata
src/main/java/ai/labs/eddi/integrations/openai/OpenAiExceptionMapper.java ExceptionMapper rendering OpenAiApiException as OpenAI error envelope
src/main/java/ai/labs/eddi/integrations/openai/model/OpenAiErrorResponse.java DTO for OpenAI error envelope
src/main/java/ai/labs/eddi/integrations/openai/model/ModelsResponse.java DTO for GET /v1/models response
src/main/java/ai/labs/eddi/integrations/openai/model/ModelObject.java DTO for model entries (snake_case fields)
src/main/java/ai/labs/eddi/integrations/openai/model/ContentPart.java DTO for polymorphic multimodal content parts (text/image/file/audio)
src/main/java/ai/labs/eddi/integrations/openai/model/ChunkChoice.java DTO for streaming chunk choice / delta semantics
src/main/java/ai/labs/eddi/integrations/openai/model/Choice.java DTO for non-streaming chat completion choices
src/main/java/ai/labs/eddi/integrations/openai/model/ChatMessage.java DTO for OpenAI chat messages with polymorphic content
src/main/java/ai/labs/eddi/integrations/openai/model/ChatCompletionResponse.java DTO for non-streaming chat completion response
src/main/java/ai/labs/eddi/integrations/openai/model/ChatCompletionRequest.java DTO for chat completion request (tolerant binding, user as JsonNode, stateless extension)
src/main/java/ai/labs/eddi/integrations/openai/model/ChatCompletionChunk.java DTO for streaming completion chunks
src/main/resources/application.properties Adds eddi.openai-compat.* settings and explicit /v1/* auth permission entry
src/test/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapterTest.java Tests request-shaping logic (not QuarkusTest) incl. stateless override + config gate
src/test/java/ai/labs/eddi/integrations/openai/OpenAiWireFormatTest.java Pins exact JSON serialization shape (snake_case fields, omission behavior)
src/test/java/ai/labs/eddi/integrations/openai/OpenAiTestFixtures.java Shared test fixtures / config builder
src/test/java/ai/labs/eddi/integrations/openai/OpenAiStartupGuardTest.java Tests startup guard failure/pass combinations
src/test/java/ai/labs/eddi/integrations/openai/OpenAiSseWriterTest.java Tests byte-level SSE framing, flushing, idempotence, disconnect tolerance
src/test/java/ai/labs/eddi/integrations/openai/OpenAiMessageMapperTest.java Tests mapping of text/system/multimodal/file/audio parts into EDDI input + contexts
src/test/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridgeTest.java Tests conversation isolation, mapping reuse/races, HITL discrimination, sync + streaming behavior
src/test/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilterTest.java Tests shared-key auth + identity resolution + OIDC mode behavior
src/test/java/ai/labs/eddi/integrations/openai/AgentModelResolverTest.java Tests model id building, ambiguity guards, stateless suffix behavior, caching
docs/open-webui-integration.md End-user integration guide for Open WebUI + OpenAI-protocol clients
docs/changelog.md Changelog entry documenting the new adapter and design decisions
AGENTS.md Adds roadmap row referencing the new OpenAI-compatible API adapter

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🧹 Nitpick comments (8)
src/test/java/ai/labs/eddi/integrations/openai/OpenAiTestFixtures.java (1)

31-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use top-level imports for Consumer and Optional.

The Java guidelines require simple names with top-level imports; avoid fully qualified types inline.

Proposed fix
 package ai.labs.eddi.integrations.openai;
 
+import java.util.Optional;
+import java.util.function.Consumer;
+
 import ai.labs.eddi.engine.model.Deployment.Environment;
 
 ...
-static OpenAiCompatConfig config(java.util.function.Consumer<ConfigBuilder> customizer) {
+static OpenAiCompatConfig config(Consumer<ConfigBuilder> customizer) {
 ...
-        return new OpenAiCompatConfig(enabled, java.util.Optional.ofNullable(apiKey), httpPolicy,
+        return new OpenAiCompatConfig(enabled, Optional.ofNullable(apiKey), httpPolicy,

As per coding guidelines, Java files must use simple names with top-level imports.

Also applies to: 51-54

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/ai/labs/eddi/integrations/openai/OpenAiTestFixtures.java`
around lines 31 - 34, Update OpenAiTestFixtures to import Consumer and Optional
at the top of the file, then replace their fully qualified inline usages in
config and the additional referenced code with the simple type names. Preserve
the existing behavior and method signatures.

Source: Coding guidelines

src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java (1)

147-149: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No metric for concurrency-limit rejections.

inFlight.tryAcquire() failures (both the sync path at Lines 147-149 and the streaming body at Lines 192-196) surface only as a 429/in-band notice; there's no Micrometer counter tracking how often the adapter is saturated, unlike the turn-level metrics already present in OpenAiConversationBridge. A simple counter here would help diagnose whether eddi.openai-compat.max-concurrent-requests needs tuning in production.

Based on coding guidelines: "Add Micrometer counters, timers, or gauges to new features and initialize them with @PostConstruct."

Also applies to: 192-196

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java` around
lines 147 - 149, In RestOpenAiAdapter, add a Micrometer counter initialized via
`@PostConstruct` and increment it whenever inFlight.tryAcquire() rejects a
request, covering both the synchronous path and streaming body path. Use the
counter to track concurrency-limit rejections while preserving the existing busy
exception and in-band notice behavior.

Source: Coding guidelines

src/main/java/ai/labs/eddi/integrations/openai/OpenAiExceptionMapper.java (1)

24-30: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log 5xx renderings before returning them.

Server-side failures are converted to a client envelope with no server-side trace, so a 500 from /v1 leaves nothing in the logs to diagnose. As per coding guidelines, "Use JBoss Logger rather than System.out, include conversation context, and select appropriate DEBUG, INFO, and ERROR levels."

♻️ Proposed refactor
+    private static final Logger LOGGER = Logger.getLogger(OpenAiExceptionMapper.class);
+
     `@Override`
     public Response toResponse(OpenAiApiException exception) {
+        if (exception.getStatus() >= 500) {
+            LOGGER.errorf(exception, "OpenAI-compatible request failed (status=%d, code=%s): %s",
+                    exception.getStatus(), exception.getCode(), exception.getMessage());
+        } else {
+            LOGGER.debugf("OpenAI-compatible request rejected (status=%d, code=%s)",
+                    exception.getStatus(), exception.getCode());
+        }
         return Response.status(exception.getStatus())
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/integrations/openai/OpenAiExceptionMapper.java`
around lines 24 - 30, Update OpenAiExceptionMapper.toResponse to log server-side
(5xx) OpenAiApiException responses before building and returning the client
error envelope. Use the project’s JBoss Logger, include available conversation
context and exception details, and keep lower-status responses on their existing
path without adding unnecessary logging.

Source: Coding guidelines

src/test/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilterTest.java (1)

59-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use a static import instead of the inlined fully qualified org.mockito.Mockito.doAnswer.

As per coding guidelines, "Use simple names with top-level imports; do not inline fully qualified names except when disambiguating same-named types."

♻️ Proposed refactor
+import static org.mockito.Mockito.doAnswer;
@@
-        org.mockito.Mockito.doAnswer(inv -> {
+        doAnswer(inv -> {
             properties.put(inv.getArgument(0), inv.getArgument(1));
             return null;
         }).when(requestContext).setProperty(any(), any());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilterTest.java`
around lines 59 - 62, Replace the fully qualified org.mockito.Mockito.doAnswer
invocation in the requestContext mock setup with a static import for doAnswer,
while preserving the existing lambda behavior and
when(requestContext).setProperty(any(), any()) configuration.

Source: Coding guidelines

src/main/java/ai/labs/eddi/integrations/openai/OpenAiApiException.java (1)

26-55: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a cause-carrying constructor; use Response.Status.TOO_MANY_REQUESTS instead of the literal.

serverError/unavailable/timeout are the wrappers for downstream failures, but there is no way to attach the original exception, so the root cause is lost from logs.

♻️ Proposed refactor
     public OpenAiApiException(int status, String type, String code, String message) {
-        super(message);
+        this(status, type, code, message, null);
+    }
+
+    public OpenAiApiException(int status, String type, String code, String message, Throwable cause) {
+        super(message, cause);
         this.status = status;
         this.type = type;
         this.code = code;
     }
@@
     public static OpenAiApiException busy(String message) {
-        return new OpenAiApiException(429, OpenAiErrorResponse.TYPE_RATE_LIMIT, null, message);
+        return new OpenAiApiException(Response.Status.TOO_MANY_REQUESTS.getStatusCode(),
+                OpenAiErrorResponse.TYPE_RATE_LIMIT, null, message);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/integrations/openai/OpenAiApiException.java`
around lines 26 - 55, Update OpenAiApiException with a cause-carrying
constructor that delegates to the existing message/status/type/code
initialization and preserves the original throwable via the superclass. Use this
constructor in the serverError, unavailable, and timeout factory methods so
downstream failures retain their causes, and replace the literal 429 in busy
with Response.Status.TOO_MANY_REQUESTS.getStatusCode().
src/main/java/ai/labs/eddi/integrations/openai/AgentModelResolver.java (1)

91-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider registering Micrometer instruments for catalogue rebuilds and unknown/ambiguous model resolutions.

As per coding guidelines, "Add Micrometer counters, timers, or gauges to new features and initialize them with @PostConstruct." A rebuild timer plus counters for unknown/ambiguous resolutions would make model-routing failures visible.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/ai/labs/eddi/integrations/openai/AgentModelResolver.java`
around lines 91 - 97, Update AgentModelResolver.initCache and the surrounding
model-resolution flow to initialize Micrometer instruments in a `@PostConstruct`
method: add a timer for catalogue rebuilds and counters for unknown and
ambiguous model resolutions. Record the timer around rebuild work and increment
the corresponding counter whenever each resolution outcome occurs, using the
project’s established MeterRegistry integration.

Source: Coding guidelines

src/test/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridgeTest.java (1)

429-485: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace inlined fully qualified names with imports.

java.io.ByteArrayOutputStream and java.nio.charset.StandardCharsets are spelled out at Lines 434, 438, 450, 452, 454, 465, 467, 469, 480, 482 and 484; the same applies to java.util.function.Consumer (Line 119) and ai.labs.eddi.engine.model.Context (Line 519). None disambiguate a same-named type.

As per coding guidelines: "Use simple names with top-level imports; do not inline fully qualified names except when disambiguating same-named types, and remove unused imports."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/test/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridgeTest.java`
around lines 429 - 485, Replace the fully qualified references to
ByteArrayOutputStream, StandardCharsets, Consumer, and Context in
OpenAiConversationBridgeTest with simple names backed by top-level imports.
Remove any imports made unused by this change, while preserving the existing
test behavior.

Source: Coding guidelines

src/test/java/ai/labs/eddi/integrations/openai/OpenAiMessageMapperTest.java (1)

324-343: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a case for file_data that is not a data: URI.

Every file-part test sends a well-formed data: URI. A bare base64 file_data (e.g. "abc,def") currently reaches fromDataUri unchecked and throws — see the comment on OpenAiMessageMapper.java Lines 264-295. A test asserting the part is skipped or accepted, rather than blowing up the turn, would pin that behaviour.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/test/java/ai/labs/eddi/integrations/openai/OpenAiMessageMapperTest.java`
around lines 324 - 343, The file-part tests lack coverage for bare, non-data-URI
file_data values. Add a test alongside filePart_withoutDataOrId_isSkipped and
filePart_unnamed_stillGetsAnAttachment using a value such as “abc,def”, and
assert the mapper handles it without throwing by verifying the intended skipped
or accepted attachment behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/open-webui-integration.md`:
- Around line 418-436: The behavior example in `docs/open-webui-integration.md`
must comply with the repository’s actionmatcher requirement: update the `Always
answer` rule’s conditions to include an appropriate `actionmatcher` targeting
`lastStep`, while preserving its intended per-turn behavior, and remove the
statement claiming an intentional departure from `AGENTS.md`.
- Around line 135-150: Update the fenced Markdown blocks around the “A turn, end
to end” section and the block at the later referenced section: add appropriate
language identifiers to each fence to satisfy MD040, and remove the blank line
inside the blockquote so it remains a continuous block and satisfies MD028.

In `@src/main/java/ai/labs/eddi/integrations/openai/AgentModelResolver.java`:
- Around line 288-296: Update the model catalogue population around
byModelId.putIfAbsent in AgentModelResolver to detect when an existing entry
already uses the same lowercased modelId for a different agent. Log a clear
collision warning that includes the modelId and affected agent identifiers,
while preserving the existing first-entry selection and other index population
behavior.

In `@src/main/java/ai/labs/eddi/integrations/openai/OpenAiCompatConfig.java`:
- Around line 49-73: Validate constructor inputs in OpenAiCompatConfig: require
httpPolicy to be exactly an accepted policy value, including rejecting typos,
case differences, and surrounding whitespace, and reject non-positive
requestTimeoutSeconds and maxConcurrentRequests. Fail fast during construction
with a clear validation exception before assigning these fields, while
preserving valid configuration behavior.

In
`@src/main/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridge.java`:
- Around line 237-241: Update the exception handling in OpenAiConversationBridge
methods around the conversation-start and related failure paths (including the
code at lines 223, 240, and 476) so OpenAiApiException.serverError receives a
generic client-facing message instead of concatenating e.getMessage(). Keep the
detailed exception message in LOGGER.errorf for internal diagnostics.

In `@src/main/java/ai/labs/eddi/integrations/openai/OpenAiMessageMapper.java`:
- Around line 264-295: Update fromDataUri to validate the data: scheme before
slicing metadata. When the input lacks the prefix, treat it as raw base64 by
creating the attachment with the filename-derived fallback MIME and storing the
entire input as attachment data; retain the existing malformed-separator
handling and data-URI parsing for prefixed inputs.
- Around line 253-262: Update the logging in OpenAiMessageMapper to sanitize all
client-supplied values before interpolation, including fileName, file.fileId(),
and audio.format() near the referenced audio logging. Reuse
ai.labs.eddi.utils.LogSanitizer.sanitize consistently while preserving the
existing warning messages and control flow.

In `@src/main/java/ai/labs/eddi/integrations/openai/OpenAiSseWriter.java`:
- Around line 103-121: Update emit in OpenAiSseWriter so Jackson serialization
exceptions such as WriteValueException or JsonProcessingException are caught
before the general IOException handler. Route serialization failures to the
existing warning-and-drop behavior without calling markBroken, while retaining
markBroken for genuine IO write or flush failures.

In `@src/main/java/ai/labs/eddi/integrations/openai/OpenAiStartupGuard.java`:
- Around line 73-76: Update OpenAiStartupGuard.isUnprotected so keyless permit
mode is allowed only when eddi.security.allow-unauthenticated is explicitly
enabled, independent of authorization.enabled or global OIDC state; retain the
existing OIDC and API-key protections. In
src/main/resources/application.properties lines 188-203, document that permit
mode requires an API key unless this escape hatch is enabled. In
planning/openai-api-adapter-plan.md lines 462-471, update the startup-guard
predicate and security model to match.

In `@src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java`:
- Around line 96-115: Update chatCompletions and streamingResponse to populate
every response or chunk model field with the resolved model’s
canonicalModelId(), matching retrieveModel and GET /v1/models. Stop using the
raw request.model() value after model resolution, while preserving the existing
response and streaming behavior.

---

Nitpick comments:
In `@src/main/java/ai/labs/eddi/integrations/openai/AgentModelResolver.java`:
- Around line 91-97: Update AgentModelResolver.initCache and the surrounding
model-resolution flow to initialize Micrometer instruments in a `@PostConstruct`
method: add a timer for catalogue rebuilds and counters for unknown and
ambiguous model resolutions. Record the timer around rebuild work and increment
the corresponding counter whenever each resolution outcome occurs, using the
project’s established MeterRegistry integration.

In `@src/main/java/ai/labs/eddi/integrations/openai/OpenAiApiException.java`:
- Around line 26-55: Update OpenAiApiException with a cause-carrying constructor
that delegates to the existing message/status/type/code initialization and
preserves the original throwable via the superclass. Use this constructor in the
serverError, unavailable, and timeout factory methods so downstream failures
retain their causes, and replace the literal 429 in busy with
Response.Status.TOO_MANY_REQUESTS.getStatusCode().

In `@src/main/java/ai/labs/eddi/integrations/openai/OpenAiExceptionMapper.java`:
- Around line 24-30: Update OpenAiExceptionMapper.toResponse to log server-side
(5xx) OpenAiApiException responses before building and returning the client
error envelope. Use the project’s JBoss Logger, include available conversation
context and exception details, and keep lower-status responses on their existing
path without adding unnecessary logging.

In `@src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java`:
- Around line 147-149: In RestOpenAiAdapter, add a Micrometer counter
initialized via `@PostConstruct` and increment it whenever inFlight.tryAcquire()
rejects a request, covering both the synchronous path and streaming body path.
Use the counter to track concurrency-limit rejections while preserving the
existing busy exception and in-band notice behavior.

In `@src/test/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilterTest.java`:
- Around line 59-62: Replace the fully qualified org.mockito.Mockito.doAnswer
invocation in the requestContext mock setup with a static import for doAnswer,
while preserving the existing lambda behavior and
when(requestContext).setProperty(any(), any()) configuration.

In
`@src/test/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridgeTest.java`:
- Around line 429-485: Replace the fully qualified references to
ByteArrayOutputStream, StandardCharsets, Consumer, and Context in
OpenAiConversationBridgeTest with simple names backed by top-level imports.
Remove any imports made unused by this change, while preserving the existing
test behavior.

In `@src/test/java/ai/labs/eddi/integrations/openai/OpenAiMessageMapperTest.java`:
- Around line 324-343: The file-part tests lack coverage for bare, non-data-URI
file_data values. Add a test alongside filePart_withoutDataOrId_isSkipped and
filePart_unnamed_stillGetsAnAttachment using a value such as “abc,def”, and
assert the mapper handles it without throwing by verifying the intended skipped
or accepted attachment behavior.

In `@src/test/java/ai/labs/eddi/integrations/openai/OpenAiTestFixtures.java`:
- Around line 31-34: Update OpenAiTestFixtures to import Consumer and Optional
at the top of the file, then replace their fully qualified inline usages in
config and the additional referenced code with the simple type names. Preserve
the existing behavior and method signatures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 745ca7c4-b6dc-4213-bde3-3b67670a3d78

📥 Commits

Reviewing files that changed from the base of the PR and between f140f14 and 54d4813.

📒 Files selected for processing (34)
  • AGENTS.md
  • docs/changelog.md
  • docs/open-webui-integration.md
  • planning/openai-api-adapter-plan.md
  • src/main/java/ai/labs/eddi/integrations/openai/AgentModelResolver.java
  • src/main/java/ai/labs/eddi/integrations/openai/OpenAiApiException.java
  • src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java
  • src/main/java/ai/labs/eddi/integrations/openai/OpenAiCompatConfig.java
  • src/main/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridge.java
  • src/main/java/ai/labs/eddi/integrations/openai/OpenAiExceptionMapper.java
  • src/main/java/ai/labs/eddi/integrations/openai/OpenAiMessageMapper.java
  • src/main/java/ai/labs/eddi/integrations/openai/OpenAiSseWriter.java
  • src/main/java/ai/labs/eddi/integrations/openai/OpenAiStartupGuard.java
  • src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/ChatCompletionChunk.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/ChatCompletionRequest.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/ChatCompletionResponse.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/ChatMessage.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/Choice.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/ChunkChoice.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/ContentPart.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/ModelObject.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/ModelsResponse.java
  • src/main/java/ai/labs/eddi/integrations/openai/model/OpenAiErrorResponse.java
  • src/main/resources/application.properties
  • src/test/java/ai/labs/eddi/integrations/openai/AgentModelResolverTest.java
  • src/test/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilterTest.java
  • src/test/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridgeTest.java
  • src/test/java/ai/labs/eddi/integrations/openai/OpenAiMessageMapperTest.java
  • src/test/java/ai/labs/eddi/integrations/openai/OpenAiSseWriterTest.java
  • src/test/java/ai/labs/eddi/integrations/openai/OpenAiStartupGuardTest.java
  • src/test/java/ai/labs/eddi/integrations/openai/OpenAiTestFixtures.java
  • src/test/java/ai/labs/eddi/integrations/openai/OpenAiWireFormatTest.java
  • src/test/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapterTest.java

Comment thread docs/open-webui-integration.md
Comment thread docs/open-webui-integration.md
Comment thread src/main/java/ai/labs/eddi/integrations/openai/OpenAiSseWriter.java
Closes the gap the unit tests structurally could not reach.

The adapter serves sync and streaming from a SINGLE JAX-RS method, dispatching
on the 'stream' body field rather than the Accept header, because
openai-python hardcodes Accept: application/json regardless of stream=True and
Open WebUI sends no Accept header at all. A refactor toward content negotiation
-- two methods with different @produces -- would look correct in every unit
test and silently return JSON to every streaming client. That guard can only be
written at the HTTP layer, which is why this test exists:

  * stream:true + Accept: application/json  -> text/event-stream
  * stream:true + no Accept header          -> text/event-stream
  * stream:false + Accept: text/event-stream -> application/json

16 tests in total, also covering the response wire shape (including that
finish_reason and owned_by are snake_cased and usage is absent), per-chat
conversation isolation end to end, both stateless routes, the error envelopes,
and tolerance of the unknown fields every client sends.

Deploys the shared minimal agent (parser/rules/output/templating) rather than
mocking, so the whole path is exercised. No LLM, so no provider credentials are
needed and responses are deterministic. Models are addressed by bare agent id,
a documented resolution route, so the test does not depend on how the shared
fixture names its descriptor.

CI ONLY. Quarkus cannot boot in the dev sandbox -- 'Unable to establish
loopback connection' -- the same environmental limit that already fails
SlackWebApiClientTest. The test compiles and failsafe discovers all 16 cases
locally, but whether they pass is for CI to determine; I have not seen them go
green.
Copilot AI review requested due to automatic review settings July 28, 2026 13:33

Copilot AI 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.

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (3)

src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java:146

  • request can be null when the POST body is missing/empty, but request.isStreaming() is dereferenced unconditionally. This will throw a NullPointerException and return a 500 instead of an OpenAI-shaped 400 error.
            if (request.isStreaming()) {
                return streamingResponse(turn, completionId, request.model(), created);

src/main/java/ai/labs/eddi/integrations/openai/OpenAiSseWriter.java:92

  • finish() writes the [DONE] frame but does not flush afterwards. On some servlet/proxy stacks this can delay delivery of the terminator, making clients think the stream is still open.
        finished = true;
        role();
        emit(ChunkChoice.finish(finishReason));
        write(DONE_FRAME);
    }

src/main/java/ai/labs/eddi/integrations/openai/OpenAiConversationBridge.java:476

  • When an exception has a null message (e.g., NPE), the non-streaming error path logs and returns "...: null". Using the existing describe() helper avoids leaking the literal word "null" to callers and keeps messages consistent with the streaming path.
        LOGGER.errorf("OpenAI adapter turn failed: %s", e.getMessage());
        return OpenAiApiException.serverError(null, "The agent could not process the message: " + e.getMessage());

CI's Secret Scanning job failed on five findings, all in documentation and all
false positives: four curl-auth-header hits on literal 'Authorization: Bearer
sk-...' strings, and one generic-api-key anchored on the 'api-key' keyword
inside an ASCII request diagram. No finding was in source code -- notably the
integration test's API key constant was not flagged.

Fixed at the source rather than suppressed via .gitleaksignore. Fingerprints
there are commit-scoped, so a suppression would need re-adding every time the
line moves, and it would hide the pattern rather than remove it. Parameterising
the credential is also better documentation practice: readers should not be
shown a curl command that puts a bearer token into their shell history.

  curl -H "Authorization: Bearer $EDDI_API_KEY" ...
  client = OpenAI(..., api_key=os.environ["EDDI_API_KEY"])

The diagram placeholder '<api-key>' becomes '<token>', which reads the same
without the keyword the generic rule anchors on.

Everything else in this CI run was green, including the new OpenAiCompatIT:
16 tests, 0 failures -- so the stream-dispatch design is now verified against a
real HTTP stack rather than only reasoned about.
Copilot AI review requested due to automatic review settings July 28, 2026 15:18

Copilot AI 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.

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java:146

  • chatCompletions dereferences request (request.isStreaming(), request.model()) even though an empty/missing JSON body can be bound as null, which will throw a NullPointerException and bypass the OpenAI error envelope. This also makes the "missing model" case surface as a 404 instead of a 400. Validate the request (and its required model) up front and fail with a 400 OpenAI error before resolving the model.
        requireEnabled();

        var model = applyStatelessOverride(resolveOrFail(request == null ? null : request.model()), request);
        String userId = requireUserId(requestContext);
        Map<String, String> headers = flatten(httpHeaders);

        if (!inFlight.tryAcquire()) {
            throw OpenAiApiException.busy("Too many concurrent requests. Please retry shortly.");
        }
        try {
            var turn = bridge.prepare(model, request, headers, userId);
            String completionId = "chatcmpl-eddi-" + UUID.randomUUID();
            long created = Instant.now().getEpochSecond();

            if (request.isStreaming()) {
                return streamingResponse(turn, completionId, request.model(), created);

src/main/java/ai/labs/eddi/integrations/openai/OpenAiSseWriter.java:92

  • OpenAiSseWriter.finish() writes the [DONE] sentinel but never flushes afterward. If the underlying output stream buffers (or the container doesn’t flush on close promptly), clients may not observe the terminator in a timely way, which can look like a hung stream.
    public void finish(String finishReason) {
        if (finished) {
            return;
        }
        finished = true;
        role();
        emit(ChunkChoice.finish(finishReason));
        write(DONE_FRAME);
    }

…ndings

The previous commit parameterised the curl and Python examples, but Secret
Scanning still failed with the same five findings at the same line numbers --
because gitleaks scans the PR's commit range, not the working tree. The
literals remain in 3b384d9 and 5283853, and no later commit can remove them
without rewriting history, which this repo forbids.

.gitleaksignore is the intended mechanism for exactly this, and its
fingerprints are commit-scoped precisely because the scan walks history. Five
entries added with the justification the file's header asks for.

Both halves are wanted: these entries clear the historical findings, and the
previous commit's env-var examples stop any future commit re-triggering the
rule. All five are placeholder credentials in documentation; none was ever a
real secret, and none was in source code.
Copilot AI review requested due to automatic review settings July 28, 2026 15:22

Copilot AI 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.

Pull request overview

Copilot reviewed 45 out of 45 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java:145

  • chatCompletions() tolerates a null request when resolving the model, but then unconditionally dereferences request (request.isStreaming(), request.model(), request.includeUsage()), which will throw a NullPointerException if the JSON body is missing/empty (RESTEasy Reactive can pass null for an empty body). This should fail with an OpenAI-shaped 400 instead of a 500.
        requireEnabled();

        var model = applyStatelessOverride(resolveOrFail(request == null ? null : request.model()), request);
        String userId = requireUserId(requestContext);
        Map<String, String> headers = flatten(httpHeaders);

…eDoS

Two of the three CodeQL alerts on this PR.

log-injection (medium, PostgresUserConversationStore): the delete path
logged `intent` unsanitized. The OpenAI adapter builds that intent from a
request header, so a newline in a chat id could forge log entries. Routed
through the existing LogSanitizer, as the bridge already does. The Mongo
store has no logging, so nothing to mirror there.

polynomial-redos (high, AgentModelResolver.slugify): replaces the dash
trim `(^-+|-+$)` with a character walk. This is NOT a fixed
vulnerability. The alert is a false positive twice over — the preceding
NON_SLUG_CHARS pass collapses runs so `-+` can never match more than one
character, and the regex measures linear regardless (3ms on 400k
separators; the engine anchors on `$` instead of backtracking). It is
replaced because a standing high-severity alert competes for attention
with real ones, and the replacement is no harder to read.

The first version of this change carried a timing assertion. Measuring
the old regex showed it would have passed against both implementations,
so it was removed rather than shipped: a test that cannot fail is worse
than none. What remains pins trimming behaviour across the swap, and is
mutation-checked — stubbing out the trim kills 3 tests.

The third alert (java/user-controlled-bypass in OpenAiAuthFilter) is
untouched pending review; it is auth code and the early return on a
non-/v1 path looks inherent to any path-scoped filter.
Copilot AI review requested due to automatic review settings July 28, 2026 22:18

Copilot AI 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.

Pull request overview

Copilot reviewed 46 out of 46 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java:171

  • The Javadoc says constantTimeEquals does not leak the length relationship, but MessageDigest.isEqual returns immediately when the byte array lengths differ. Either adjust the comment to avoid over-claiming, or implement a length-hiding comparison if that property is required.
    /**
     * Compare secrets without leaking their length relationship through timing.
     * {@code String.equals} short-circuits on the first differing character.
     */

src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java:145

  • chatCompletions(...) tolerates a null request in applyStatelessOverride(...), but then unconditionally calls request.isStreaming() / request.model() / request.includeUsage(), which will throw a NullPointerException on an empty or unreadable body. This should fail as a 400 OpenAI error envelope instead.
        requireEnabled();

        var model = applyStatelessOverride(resolveOrFail(request == null ? null : request.model()), request);
        String userId = requireUserId(requestContext);
        Map<String, String> headers = flatten(httpHeaders);

src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java:94

  • When user identity cannot be resolved, the filter aborts via abort(...), which always builds an OpenAiApiException.unauthorized(...) envelope. That forces error.code="invalid_api_key" even when the API key was correct and the failure is "missing/invalid user identity". This can mislead clients/operators; consider omitting code (null) for this case or introducing a distinct adapter code.

This issue also appears on line 168 of the same file.

            abort(requestContext, Response.Status.UNAUTHORIZED.getStatusCode(),
                    "Could not determine the calling user. Send X-OpenWebUI-User-Id "
                            + "(with eddi.openai-compat.trust-user-headers=true), authenticate via OIDC, "
                            + "or set eddi.openai-compat.allow-anonymous=true.");
            return;

src/main/java/ai/labs/eddi/datastore/postgres/PostgresUserConversationStore.java:144

  • PR description says "no existing Java file is modified", but this PR updates PostgresUserConversationStore. Please update the PR description to match the actual change set (or clarify that there is one non-adapter Java change for log sanitization).
            // Sanitized: the intent embeds a caller-supplied chat key (the OpenAI
            // adapter builds channel:openai:<agentId>:<chatKey> from a request
            // header), so a newline in it could forge log entries.
            LOGGER.error("Failed to delete user conversation intent=" + sanitize(intent), e);

…th a test

CodeQL reports java/user-controlled-bypass against the early return in
OpenAiAuthFilter.filter: a user-controlled path decides whether the API
key check runs. It does, but the filter is neither the only nor the first
check in front of those paths.

application.properties ends with
quarkus.http.auth.permission.authenticated.paths=/,/* at policy
authenticated, and Quarkus HTTP authorization runs before JAX-RS filters,
so every path the filter declines has already been required to
authenticate. The sole exception is /v1/*, exempted to 'permit' precisely
so the shared API key can be checked in the filter instead of being
rejected at the OIDC layer as a malformed JWT — and that is exactly the
set isGuarded returns true for. The guard chooses between the adapter's
key check and Quarkus' own, not between authenticated and anonymous.

Records that on isGuarded rather than only in the GitHub dismissal, so it
is found where the suspicious-looking code is.

Adds a test, because the argument rests entirely on two config lines and
a finding dismissed on unchecked config is how a real bypass ships. It
asserts the catch-all paths, the catch-all policy and the /v1/* exemption
against src/main/resources/application.properties; flipping the policy to
permit fails it. It reads from source deliberately: the first version
loaded the file from the classpath, where src/test/resources shadows it
and declares none of these keys.
Copilot AI review requested due to automatic review settings July 28, 2026 22:41

Copilot AI 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.

Pull request overview

Copilot reviewed 46 out of 46 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (5)

src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java:146

  • chatCompletions partially treats the request body as nullable (it null-checks before reading model), but then immediately dereferences request (request.isStreaming(), request.model(), request.includeUsage()), which will throw an NPE if the body is empty/unparseable and JAX-RS supplies null. This should fail with an OpenAI-shaped 400 instead of a 500.
        requireEnabled();

        var model = applyStatelessOverride(resolveOrFail(request == null ? null : request.model()), request);
        String userId = requireUserId(requestContext);
        Map<String, String> headers = flatten(httpHeaders);

src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java:195

  • The Javadoc claims constantTimeEquals avoids leaking the length relationship, but MessageDigest.isEqual() returns immediately when array lengths differ. Hashing first (fixed-length digests) avoids that short-circuit and better matches the comment’s intent.
    /**
     * Compare secrets without leaking their length relationship through timing.
     * {@code String.equals} short-circuits on the first differing character.
     */
    private static boolean constantTimeEquals(String presented, String expected) {
        return MessageDigest.isEqual(presented.getBytes(StandardCharsets.UTF_8),
                expected.getBytes(StandardCharsets.UTF_8));
    }

src/test/java/ai/labs/eddi/integration/OpenAiCompatIT.java:40

  • This PR adds HTTP-level coverage for the /v1 adapter (this @QuarkusTest IT), but the PR description’s “Known gap” section says no real-HTTP test exists. Please update the PR description (or adjust this test’s scope) so reviewers don’t miss that the gap is already addressed here.
    src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java:96
  • When user identity can’t be resolved, the filter aborts with OpenAiApiException.unauthorized(...), which always uses error.code=invalid_api_key (see OpenAiApiException.unauthorized). That code is accurate for a missing/wrong API key, but misleading for “missing X-OpenWebUI-User-Id / anonymous identity refused” and may cause clients to prompt users to change the API key instead of fixing identity forwarding.
        String userId = resolveUserId(requestContext);
        if (userId == null) {
            abort(requestContext, Response.Status.UNAUTHORIZED.getStatusCode(),
                    "Could not determine the calling user. Send X-OpenWebUI-User-Id "
                            + "(with eddi.openai-compat.trust-user-headers=true), authenticate via OIDC, "
                            + "or set eddi.openai-compat.allow-anonymous=true.");
            return;
        }
        requestContext.setProperty(PROP_USER_ID, userId);

src/main/java/ai/labs/eddi/datastore/postgres/PostgresUserConversationStore.java:145

  • PR description says “no existing Java file is modified”, but this change updates an existing core datastore class. Please update the PR description to reflect this (even if the change is intentional and small) so reviewers don’t miss non-adapter edits.
            // Sanitized: the intent embeds a caller-supplied chat key (the OpenAI
            // adapter builds channel:openai:<agentId>:<chatKey> from a request
            // header), so a newline in it could forge log entries.
            LOGGER.error("Failed to delete user conversation intent=" + sanitize(intent), e);
        }

Copilot AI review requested due to automatic review settings July 29, 2026 00:49

Copilot AI 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.

Pull request overview

Copilot reviewed 46 out of 46 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

src/main/java/ai/labs/eddi/integrations/openai/OpenAiSseWriter.java:113

  • finish() writes the final data: [DONE] frame but never flushes the stream afterward. This contradicts the class contract (“Every frame is flushed immediately”) and can leave some clients/proxies waiting on the sentinel until the connection closes.
    public void finish(String finishReason) {
        if (finished) {
            return;
        }
        finished = true;
        role();
        emit(ChunkChoice.finish(finishReason));
        if (includeUsage && usage != null) {
            emitChunk(ChatCompletionChunk.usageOnly(id, model, created, usage));
        }
        write(DONE_FRAME);
    }

src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java:96

  • When user identity cannot be resolved (missing/blank X-OpenWebUI-User-Id in api-key mode, or anonymous OIDC identity), the filter aborts with a 401 but uses OpenAiApiException.unauthorized(...), which hardcodes the OpenAI error code to invalid_api_key. This makes identity/authorization failures indistinguishable from a bad API key for clients and operators.
        String userId = resolveUserId(requestContext);
        if (userId == null) {
            abort(requestContext, Response.Status.UNAUTHORIZED.getStatusCode(),
                    "Could not determine the calling user. Send X-OpenWebUI-User-Id "
                            + "(with eddi.openai-compat.trust-user-headers=true), authenticate via OIDC, "
                            + "or set eddi.openai-compat.allow-anonymous=true.");
            return;
        }
        requestContext.setProperty(PROP_USER_ID, userId);

The merge left the 2026-07-28 "code-review findings wave 1" entry below a
2026-07-27 one, in a file that is explicitly newest-first. Moves that one
block above the 07-27 OpenAI adapter entry; no other entry changes
position and no content changes.

Also repairs two seam defects my own merge resolution introduced: a
doubled "---" before the wave-1 entry, and a missing one before the
Keycloak entry.

Deliberately a targeted block move, not a re-sort. Sorting the file by
date was tried first and reverted: `---` is not a 1:1 entry delimiter
here (355 headings, 339 separators), so splitting on it does not yield
entries, and the sort rewrote ~37% of an 11.6k-line historical document
and pushed undated entries to the bottom. This version is anchored on
"## " headings and asserts that the set of non-blank, non-separator lines
is unchanged.
Copilot AI review requested due to automatic review settings July 29, 2026 06:43

Copilot AI 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.

Pull request overview

Copilot reviewed 46 out of 46 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

src/main/java/ai/labs/eddi/integrations/openai/model/ContentPart.java:53

  • isInputAudio() treats an empty/blank base64 payload as valid, which can produce an attachment with blank data and trigger warnings/drops downstream (AttachmentContextExtractor requires non-blank url/data). Consider requiring non-blank data here.
    public boolean isInputAudio() {
        return TYPE_INPUT_AUDIO.equals(type) && inputAudio != null && inputAudio.data() != null;
    }

src/main/java/ai/labs/eddi/integrations/openai/model/ContentPart.java:49

  • isImageUrl() treats an empty/blank URL as valid, which can produce an attachment with a blank url and trigger warnings/drops downstream (AttachmentContextExtractor requires non-blank url/data). It’s better to treat blank URLs as absent at the binding layer.
    public boolean isImageUrl() {
        return TYPE_IMAGE_URL.equals(type) && imageUrl != null && imageUrl.url() != null;
    }

src/main/java/ai/labs/eddi/integrations/openai/OpenAiSseWriter.java:94

  • usage()’s Javadoc says usage is ignored when the caller didn’t request it, but the method currently stores usage unconditionally. This is harmless but inconsistent and can be made true to the comment by short-circuiting when includeUsage is false (and when the usage itself is null).
    public void usage(TokenUsage tokenUsage) {
        this.usage = tokenUsage;
    }

src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java:157

  • request can be null (e.g., missing/empty JSON body), but the method later calls request.isStreaming(), request.model() and request.includeUsage() unconditionally, which will throw a NullPointerException and return a 500 instead of an OpenAI-shaped 400.
        requireEnabled();

        var model = applyStatelessOverride(resolveOrFail(request == null ? null : request.model()), request);
        String userId = requireUserId(requestContext);
        Map<String, String> headers = flatten(httpHeaders);

ginccc added 2 commits July 29, 2026 21:37
Two defects, both found by re-running the stack rather than reading it.

Adding an LLM key later silently did nothing. The seeder guarded on "is
any model exposed" and exit 0'd if so. The MongoDB volume persists, so the
most common second run is exactly that case: the rule-based agent is
already there and the user has now set EDDI_DEMO_LLM_API_KEY to get an
agent that can actually answer questions. They got no new model and no
explanation. The guard is now per agent, keyed on the model-id prefix each
descriptor name slugifies to. Vault storage moved into its own step that
runs whenever a key is supplied, so changing the key rotates it instead of
being skipped behind an "already exists" check.

The closing "Ready" listing could omit the agent just created. The poll
exited on grep -q '"id"', which a pre-existing agent satisfies instantly,
so a freshly deployed LLM agent still inside the adapter's 30s model-cache
TTL was absent from the output and looked like a failure. Confirmed it was
display-only by re-querying after the TTL. The loop now waits for the
prefixes actually expected on this run.

Verified live against a populated volume: run 1 created the rule-based
agent, run 2 reported "already deployed - skipping" and created no
duplicate, run 3 with a key added the LLM agent and listed all four
models. The old code stopped at run 2.

Also: the final "open this URL" line hardcoded port 3000 and was wrong
under OPEN_WEBUI_PORT, so compose passes OPEN_WEBUI_URL. .env.example
gained an Open WebUI section, since the demo's .env is gitignored and
these variables were undiscoverable from a fresh clone. Docs gained
subsections for re-running, port collisions and down vs down -v - all
three bit during testing.

Doc accuracy pass found no drift: all 11 eddi.openai-compat.* keys and
defaults match application.properties, the 8 error codes all exist in
OpenAiErrorResponse, and the documented endpoints match RestOpenAiAdapter.
Copilot AI review requested due to automatic review settings July 29, 2026 20:45

Copilot AI 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.

Pull request overview

Copilot reviewed 47 out of 47 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (6)

src/main/java/ai/labs/eddi/integrations/openai/OpenAiOutputRenderer.java:203

  • Quick reply values are wrapped in backticks without escaping. If a quick reply label contains a backtick, the rendered Markdown breaks and may hide or mangle the suggested reply list.
        if (values.isEmpty()) {
            return null;
        }
        return "_Suggested replies:_ " + String.join(" · ", values.stream().map(v -> "`" + v + "`").toList());
    }

src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java:279

  • resolveOrFail() maps all UnknownModelException cases to 404 model_not_found, including when the client omitted the required model field (AgentModelResolver throws "No model was specified."). A missing required field is a malformed request and should be a 400 so clients don’t mis-handle it as “unknown model”.
        try {
            return modelResolver.resolve(modelId);
        } catch (AgentModelResolver.UnknownModelException e) {
            throw OpenAiApiException.notFound(OpenAiErrorResponse.CODE_MODEL_NOT_FOUND, e.getMessage());
        } catch (AgentModelResolver.AmbiguousModelException e) {

src/main/java/ai/labs/eddi/integrations/openai/model/ContentPart.java:53

  • ContentPart.isImageUrl()/isInputAudio() treat empty strings as valid payloads (they only check for null). That causes the mapper to build “attachments” with blank URLs / data, which are guaranteed to fail downstream and makes the adapter less tolerant of real-world clients that sometimes send empty fields.
    public boolean isImageUrl() {
        return TYPE_IMAGE_URL.equals(type) && imageUrl != null && imageUrl.url() != null;
    }

    public boolean isInputAudio() {

src/main/java/ai/labs/eddi/integrations/openai/OpenAiAuthFilter.java:195

  • The constantTimeEquals() Javadoc claims it avoids leaking the length relationship through timing, but MessageDigest.isEqual runs in time proportional to min(a.length, b.length). Either implement a length-constant compare or adjust the comment so it doesn’t over-promise what the code provides.
    /**
     * Compare secrets without leaking their length relationship through timing.
     * {@code String.equals} short-circuits on the first differing character.
     */
    private static boolean constantTimeEquals(String presented, String expected) {

src/main/java/ai/labs/eddi/integrations/openai/RestOpenAiAdapter.java:162

  • When stateless:true is set in the request body, the adapter correctly forces the resolved model to stateless, but the wire model field (JSON response + SSE frames) still echoes request.model() without the :stateless suffix. That makes the response describe a different model than the one actually executed, and it makes the body field behave differently from the suffix route.
            if (request.isStreaming()) {
                return streamingResponse(turn, completionId, request.model(), created, request.includeUsage());
            }

            var outcome = bridge.say(turn, model, userId, headers, request);

src/main/java/ai/labs/eddi/datastore/postgres/PostgresUserConversationStore.java:145

  • PR description says “no existing Java file is modified”, but this PR updates PostgresUserConversationStore (log sanitization) as part of the adapter work. Please update the PR description (or the claim) so reviewers don’t miss the non-additive change.
            // Sanitized: the intent embeds a caller-supplied chat key (the OpenAI
            // adapter builds channel:openai:<agentId>:<chatKey> from a request
            // header), so a newline in it could forge log entries.
            LOGGER.error("Failed to delete user conversation intent=" + sanitize(intent), e);
        }

@ginccc
ginccc requested a review from aisabella-ai July 29, 2026 21:05
@ginccc
ginccc merged commit fd0152b into main Jul 29, 2026
25 checks passed
@ginccc
ginccc deleted the feat/openai-api-adapter branch July 29, 2026 21:06
ginccc added a commit that referenced this pull request Jul 29, 2026
#614 merged to main and left this PR CONFLICTING — which matters beyond
tidiness, because a conflicting PR has no computable merge ref and so cannot run
CI at all.

One conflict, docs/changelog.md: both sides added a top entry. Both kept,
nothing dropped.

Verified past the textual resolution, since a clean auto-merge has silently
broken compilation twice in this stack when main changed a constructor this
branch's tests were written against: the merged tree compiles, and 208 tests
across both sides of the merge pass (WorkflowTraversal and MemoryItemConverter
from this branch, the OpenAI adapter surface from #614).
ginccc added a commit that referenced this pull request Jul 29, 2026
#614 merged and left this PR CONFLICTING, which blocks CI entirely — a
conflicting PR has no computable merge ref, so no workflow can run on it.

One conflict, docs/changelog.md: both sides added top entries. Both kept.

Verified past the resolution: the merged tree compiles and 888 tests pass across
both sides — this branch's schedule/Dream/shutdown/group work plus #614's OpenAI
adapter surface.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants