Skip to content

Commit 7fcc124

Browse files
committed
feat: support deferred tool approvals
Add structured tool approval decisions so callers can defer execution and resume after external approval.
1 parent 8ac67fb commit 7fcc124

11 files changed

Lines changed: 333 additions & 195 deletions

File tree

provider/openai/responses/responses.go

Lines changed: 14 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"errors"
77
"fmt"
88
"net/http"
9-
"strings"
109
"time"
1110

1211
"github.com/memohai/twilight-ai/internal/utils"
@@ -17,17 +16,15 @@ const (
1716
defaultBaseURL = "https://api.openai.com/v1"
1817

1918
// Output item types for OpenAI Responses API
20-
outputTypeMessage = "message"
21-
outputTypeReasoning = "reasoning"
22-
outputTypeFunctionCall = "function_call"
23-
outputTypeImageGeneration = "image_generation_call"
19+
outputTypeMessage = "message"
20+
outputTypeReasoning = "reasoning"
21+
outputTypeFunctionCall = "function_call"
2422
)
2523

2624
type Provider struct {
27-
apiKey string
28-
baseURL string
29-
httpClient *http.Client
30-
prepareRequest func(*http.Request) error
25+
apiKey string
26+
baseURL string
27+
httpClient *http.Client
3128
}
3229

3330
type Option func(*Provider)
@@ -36,22 +33,6 @@ func WithAPIKey(apiKey string) Option {
3633
return func(p *Provider) { p.apiKey = apiKey }
3734
}
3835

39-
// WithBedrockRegion enables AWS SigV4 authentication for Amazon Bedrock's
40-
// OpenAI-compatible endpoint using the default AWS credential chain.
41-
func WithBedrockRegion(region string) Option {
42-
return func(p *Provider) {
43-
p.prepareRequest = utils.NewBedrockDefaultCredentialsPreparer(region)
44-
}
45-
}
46-
47-
// WithBedrockCredentials enables AWS SigV4 authentication for Amazon Bedrock's
48-
// OpenAI-compatible endpoint using static credentials.
49-
func WithBedrockCredentials(region, accessKeyID, secretAccessKey, sessionToken string) Option {
50-
return func(p *Provider) {
51-
p.prepareRequest = utils.NewBedrockStaticCredentialsPreparer(region, accessKeyID, secretAccessKey, sessionToken)
52-
}
53-
}
54-
5536
func WithBaseURL(baseURL string) Option {
5637
return func(p *Provider) { p.baseURL = baseURL }
5738
}
@@ -78,8 +59,7 @@ func (p *Provider) ListModels(ctx context.Context) ([]sdk.Model, error) {
7859
Method: http.MethodGet,
7960
BaseURL: p.baseURL,
8061
Path: "/models",
81-
Headers: p.authHeaders(),
82-
Prepare: p.prepareRequest,
62+
Headers: utils.AuthHeader(p.apiKey),
8363
})
8464
if err != nil {
8565
return nil, fmt.Errorf("openai-responses: list models request failed: %w", err)
@@ -102,8 +82,7 @@ func (p *Provider) Test(ctx context.Context) *sdk.ProviderTestResult {
10282
BaseURL: p.baseURL,
10383
Path: "/models",
10484
Query: map[string]string{"limit": "1"},
105-
Headers: p.authHeaders(),
106-
Prepare: p.prepareRequest,
85+
Headers: utils.AuthHeader(p.apiKey),
10786
})
10887
if err != nil {
10988
return classifyError(err)
@@ -116,8 +95,7 @@ func (p *Provider) TestModel(ctx context.Context, modelID string) (*sdk.ModelTes
11695
Method: http.MethodGet,
11796
BaseURL: p.baseURL,
11897
Path: "/models/" + modelID,
119-
Headers: p.authHeaders(),
120-
Prepare: p.prepareRequest,
98+
Headers: utils.AuthHeader(p.apiKey),
12199
})
122100
if err == nil {
123101
return &sdk.ModelTestResult{Supported: true, Message: "supported"}, nil
@@ -131,8 +109,7 @@ func (p *Provider) TestModel(ctx context.Context, modelID string) (*sdk.ModelTes
131109
Method: http.MethodPost,
132110
BaseURL: p.baseURL,
133111
Path: "/responses",
134-
Headers: p.authHeaders(),
135-
Prepare: p.prepareRequest,
112+
Headers: utils.AuthHeader(p.apiKey),
136113
Body: map[string]any{
137114
"model": modelID,
138115
"input": "hi",
@@ -166,8 +143,7 @@ func (p *Provider) DoGenerate(ctx context.Context, params sdk.GenerateParams) (*
166143
Method: http.MethodPost,
167144
BaseURL: p.baseURL,
168145
Path: "/responses",
169-
Headers: p.authHeaders(),
170-
Prepare: p.prepareRequest,
146+
Headers: utils.AuthHeader(p.apiKey),
171147
Body: req,
172148
})
173149
if err != nil {
@@ -379,7 +355,7 @@ func (p *Provider) parseResponse(resp *responsesResponse) (*sdk.GenerateResult,
379355
Response: sdk.ResponseMetadata{
380356
ID: resp.ID,
381357
ModelID: resp.Model,
382-
Timestamp: time.Unix(int64(resp.CreatedAt), 0),
358+
Timestamp: time.Unix(resp.CreatedAt, 0),
383359
},
384360
}
385361

@@ -443,14 +419,6 @@ func (p *Provider) parseResponse(resp *responsesResponse) (*sdk.GenerateResult,
443419
ToolName: item.Name,
444420
Input: input,
445421
})
446-
447-
case outputTypeImageGeneration:
448-
if data := strings.TrimSpace(item.Result); data != "" {
449-
result.Files = append(result.Files, sdk.GeneratedFile{
450-
Data: data,
451-
MediaType: "image/png",
452-
})
453-
}
454422
}
455423
}
456424

@@ -523,8 +491,7 @@ func (p *Provider) DoStream(ctx context.Context, params sdk.GenerateParams) (*sd
523491
Method: http.MethodPost,
524492
BaseURL: p.baseURL,
525493
Path: "/responses",
526-
Headers: p.authHeaders(),
527-
Prepare: p.prepareRequest,
494+
Headers: utils.AuthHeader(p.apiKey),
528495
Body: req,
529496
}, func(ev *utils.SSEEvent) error {
530497
eventType := ev.Event
@@ -546,7 +513,7 @@ func (p *Provider) DoStream(ctx context.Context, params sdk.GenerateParams) (*sd
546513
}
547514
responseID = chunk.Response.ID
548515
responseModel = chunk.Response.Model
549-
responseCreated = int64(chunk.Response.CreatedAt)
516+
responseCreated = chunk.Response.CreatedAt
550517

551518
case "response.output_item.added":
552519
var chunk responsesOutputItemAddedChunk
@@ -691,28 +658,6 @@ func (p *Provider) DoStream(ctx context.Context, params sdk.GenerateParams) (*sd
691658
})
692659
}
693660

694-
case "response.image_generation_call.completed":
695-
var chunk responsesImageGenCompletedChunk
696-
if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
697-
return nil
698-
}
699-
if data := strings.TrimSpace(chunk.Result); data != "" {
700-
if textStartSent {
701-
send(&sdk.TextEndPart{ID: responseID})
702-
textStartSent = false
703-
}
704-
if reasoningStartSent {
705-
send(&sdk.ReasoningEndPart{ID: responseID})
706-
reasoningStartSent = false
707-
}
708-
send(&sdk.StreamFilePart{
709-
File: sdk.GeneratedFile{
710-
Data: data,
711-
MediaType: "image/png",
712-
},
713-
})
714-
}
715-
716661
case "response.completed", "response.incomplete":
717662
var chunk responsesCompletedChunk
718663
if err := json.Unmarshal([]byte(ev.Data), &chunk); err != nil {
@@ -775,16 +720,6 @@ func (p *Provider) DoStream(ctx context.Context, params sdk.GenerateParams) (*sd
775720
return &sdk.StreamResult{Stream: ch}, nil
776721
}
777722

778-
func (p *Provider) authHeaders() map[string]string {
779-
if p.prepareRequest != nil {
780-
return nil
781-
}
782-
if p.apiKey == "" {
783-
return nil
784-
}
785-
return utils.AuthHeader(p.apiKey)
786-
}
787-
788723
// ---------- helpers ----------
789724

790725
func mapResponsesFinishReason(incompleteReason string, hasFunctionCall bool) sdk.FinishReason {

provider/openai/responses/types.go

Lines changed: 5 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
11
package responses
22

3-
import (
4-
"encoding/json"
5-
"fmt"
6-
"math"
7-
"strconv"
8-
)
3+
import "encoding/json"
94

105
// --- Request types ---
116

@@ -100,7 +95,7 @@ type responsesReasoningItem struct {
10095

10196
type responsesResponse struct {
10297
ID string `json:"id"`
103-
CreatedAt unixTimestamp `json:"created_at"`
98+
CreatedAt int64 `json:"created_at"`
10499
Model string `json:"model"`
105100
Output []responsesOutputItem `json:"output"`
106101
Usage *responsesUsage `json:"usage,omitempty"`
@@ -134,9 +129,6 @@ type responsesOutputItem struct {
134129
CallID string `json:"call_id,omitempty"`
135130
Name string `json:"name,omitempty"`
136131
Arguments string `json:"arguments,omitempty"`
137-
138-
// type: "image_generation_call"
139-
Result string `json:"result,omitempty"`
140132
}
141133

142134
type responsesOutputContent struct {
@@ -175,49 +167,12 @@ type responsesOutputTokenDetails struct {
175167
type responsesCreatedChunk struct {
176168
Type string `json:"type"`
177169
Response struct {
178-
ID string `json:"id"`
179-
CreatedAt unixTimestamp `json:"created_at"`
180-
Model string `json:"model"`
170+
ID string `json:"id"`
171+
CreatedAt int64 `json:"created_at"`
172+
Model string `json:"model"`
181173
} `json:"response"`
182174
}
183175

184-
type unixTimestamp int64
185-
186-
func (t *unixTimestamp) UnmarshalJSON(data []byte) error {
187-
if len(data) == 0 || string(data) == "null" {
188-
*t = 0
189-
return nil
190-
}
191-
192-
var i int64
193-
if err := json.Unmarshal(data, &i); err == nil {
194-
*t = unixTimestamp(i)
195-
return nil
196-
}
197-
198-
var f float64
199-
if err := json.Unmarshal(data, &f); err == nil {
200-
*t = unixTimestamp(int64(math.Round(f)))
201-
return nil
202-
}
203-
204-
var s string
205-
if err := json.Unmarshal(data, &s); err == nil {
206-
if s == "" {
207-
*t = 0
208-
return nil
209-
}
210-
v, err := strconv.ParseFloat(s, 64)
211-
if err != nil {
212-
return fmt.Errorf("parse unix timestamp %q: %w", s, err)
213-
}
214-
*t = unixTimestamp(int64(math.Round(v)))
215-
return nil
216-
}
217-
218-
return fmt.Errorf("unsupported unix timestamp: %s", string(data))
219-
}
220-
221176
// responsesOutputItemAddedChunk is sent for event: response.output_item.added
222177
type responsesOutputItemAddedChunk struct {
223178
Type string `json:"type"`
@@ -295,13 +250,6 @@ type responsesErrorChunk struct {
295250
} `json:"error"`
296251
}
297252

298-
// responsesImageGenCompletedChunk is sent for event: response.image_generation_call.completed
299-
type responsesImageGenCompletedChunk struct {
300-
Type string `json:"type"`
301-
ItemID string `json:"item_id"`
302-
Result string `json:"result"` // base64-encoded image data
303-
}
304-
305253
// --- Models API response types ---
306254

307255
type modelsListResponse struct {

0 commit comments

Comments
 (0)