| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443 |
- //go:build unit
- package service
- import (
- "bytes"
- "context"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "sync"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletions(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.4","input":"hello","stream":false,"service_tier":"priority"}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_resp_chat_json"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"chatcmpl_json","object":"chat.completion","model":"gpt-5.4","service_tier":"default","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5,"prompt_tokens_details":{"cached_tokens":1}}}`,
- )),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- SetActualOpenAIUpstreamEndpoint(c, "/v1/responses")
- result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
- require.Equal(t, "/v1/chat/completions", GetActualOpenAIUpstreamEndpoint(c))
- require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.lastReq.Context()))
- require.Equal(t, "hello", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
- require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
- require.Equal(t, "response", gjson.Get(rec.Body.String(), "object").String())
- require.Equal(t, "ok", gjson.Get(rec.Body.String(), "output.0.content.0.text").String())
- require.Equal(t, 3, result.Usage.InputTokens)
- require.Equal(t, 2, result.Usage.OutputTokens)
- require.Equal(t, 1, result.Usage.CacheReadInputTokens)
- require.NotNil(t, result.ServiceTier)
- require.Equal(t, "priority", *result.ServiceTier)
- require.Equal(t, "default", result.UpstreamResponseServiceTier)
- require.False(t, result.Stream)
- }
- // Scenario: 第三方无推理模型不收到兼容档位。
- func TestForwardResponses_ForceChatCompletionsOmitsNoneReasoningEffort(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"company-coding-model","input":"hello","reasoning":{"effort":"none"},"stream":false}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"chatcmpl_none","object":"chat.completion","model":"company-coding-model","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
- )),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, "company-coding-model", gjson.GetBytes(upstream.lastBody, "model").String())
- require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning_effort").Exists())
- require.Nil(t, result.ReasoningEffort)
- }
- func TestForwardResponses_PassthroughFlagWithUnsupportedResponsesUsesAccountMapping(t *testing.T) {
- gin.SetMode(gin.TestMode)
- for _, path := range []string{"/v1/responses", "/v1/responses/compact"} {
- path := path
- t.Run(path, func(t *testing.T) {
- body := []byte(`{"model":"gpt-5.4-channel","input":"hello","stream":false}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"chatcmpl_mapping","object":"chat.completion","model":"gpt-5.4-account","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
- )),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- account := rawChatCompletionsTestAccount()
- account.Credentials["model_mapping"] = map[string]any{
- "gpt-5.4-channel": "gpt-5.4-account",
- }
- account.Credentials["compact_model_mapping"] = map[string]any{
- "gpt-5.4-account": "gpt-5.4-compact",
- }
- account.Extra = map[string]any{
- "openai_passthrough": true,
- openai_compat.ExtraKeyResponsesSupported: false,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
- require.Equal(t, "gpt-5.4-account", gjson.GetBytes(upstream.lastBody, "model").String())
- })
- }
- }
- func TestForwardResponses_ForceChatCompletionsRoutesStreamingToChatCompletions(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.4","input":"hello","stream":true}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"he"},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"llo"},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}`,
- "",
- `data: {"id":"chatcmpl_stream","object":"chat.completion.chunk","model":"gpt-5.4","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_resp_chat_stream"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
- require.True(t, gjson.GetBytes(upstream.lastBody, "stream_options.include_usage").Bool())
- require.Contains(t, rec.Body.String(), "event: response.output_text.delta")
- require.Contains(t, rec.Body.String(), `"delta":"he"`)
- require.Contains(t, rec.Body.String(), "event: response.completed")
- require.Contains(t, rec.Body.String(), `"input_tokens":4`)
- require.Contains(t, rec.Body.String(), "data: [DONE]")
- require.Equal(t, 4, result.Usage.InputTokens)
- require.Equal(t, 3, result.Usage.OutputTokens)
- require.True(t, result.Stream)
- require.NotNil(t, result.FirstTokenMs)
- }
- func TestForwardResponses_ChatFallbackRejectsInvalidToolArgumentsAtOutputLimit(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"deepseek-v4-flash","input":"run the command","stream":true}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"id":"chatcmpl_length_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_length","type":"function","function":{"name":"exec_command","arguments":"{\"cmd\":\"ssh root@HOST"}}]},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_length_tool","object":"chat.completion.chunk","model":"deepseek-v4-flash","choices":[{"index":0,"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":4,"completion_tokens":6492,"total_tokens":6496}}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_length_tool"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
- require.ErrorContains(t, err, "invalid JSON")
- require.NotNil(t, result)
- require.Equal(t, 4, result.Usage.InputTokens)
- require.Equal(t, 6492, result.Usage.OutputTokens)
- require.NotContains(t, rec.Body.String(), "response.function_call_arguments.done")
- require.NotContains(t, rec.Body.String(), "response.output_item.done")
- require.NotContains(t, rec.Body.String(), "data: [DONE]")
- }
- func TestForwardResponses_DeepSeekReasoningOnlyStreamProducesVisibleText(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"visible fallback"},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_reasoning","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":""},"finish_reason":"length"}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_deepseek_reasoning_responses_stream"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.True(t, result.Stream)
- require.Contains(t, rec.Body.String(), "event: response.output_text.delta")
- require.Contains(t, rec.Body.String(), `"delta":"visible fallback"`)
- require.Contains(t, rec.Body.String(), `"status":"incomplete"`)
- require.Contains(t, rec.Body.String(), "data: [DONE]")
- }
- func TestForwardResponses_AutoSupportedAccountStillUsesResponsesEndpoint(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.4","input":"hello","stream":false}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_resp_native"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"resp_native","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}],"status":"completed"}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}`,
- )),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- account := rawChatCompletionsTestAccount()
- account.Extra = map[string]any{
- openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
- openai_compat.ExtraKeyResponsesSupported: true,
- }
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, "http://upstream.example/v1/responses", upstream.lastReq.URL.String())
- require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
- require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
- require.Equal(t, "ok", gjson.Get(rec.Body.String(), "output.0.content.0.text").String())
- }
- func forceChatResponsesFallbackAccount() *Account {
- account := rawChatCompletionsTestAccount()
- account.Extra = map[string]any{
- openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
- }
- return account
- }
- // reasoningRecordingCache 记录 reasoning 缓存写入、并按需响应回查。
- type reasoningRecordingCache struct {
- stubGatewayCache
- mu sync.Mutex
- sets map[string]string
- getResp map[string]string
- }
- func (c *reasoningRecordingCache) SetReasoningContent(_ context.Context, itemID string, content string, _ time.Duration) error {
- c.mu.Lock()
- defer c.mu.Unlock()
- if c.sets == nil {
- c.sets = make(map[string]string)
- }
- c.sets[itemID] = content
- return nil
- }
- func (c *reasoningRecordingCache) GetReasoningContent(_ context.Context, itemID string) (string, error) {
- if v, ok := c.getResp[itemID]; ok {
- return v, nil
- }
- return "", ErrReasoningContentNotFound
- }
- func (c *reasoningRecordingCache) snapshotSets() map[string]string {
- c.mu.Lock()
- defer c.mu.Unlock()
- out := make(map[string]string, len(c.sets))
- for k, v := range c.sets {
- out[k] = v
- }
- return out
- }
- // 流式响应里的 reasoning_content 应按 reasoning item id 写入缓存,供后续轮次
- // 客户端不回传明文 summary 时回注(DeepSeek thinking mode 400 修复的写入侧)。
- func TestForwardResponses_ChatFallbackCachesStreamedReasoning(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"deepseek-reasoner","input":"hello","stream":true}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"think "},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"reasoning_content":"first"},"finish_reason":null}]}`,
- "",
- `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[{"index":0,"delta":{"content":"answer"},"finish_reason":"stop"}]}`,
- "",
- `data: {"id":"chatcmpl_rc","object":"chat.completion.chunk","model":"deepseek-reasoner","choices":[],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_reasoning_cache_stream"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- cache := &reasoningRecordingCache{}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- cache: cache,
- }
- result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
- require.NoError(t, err)
- require.NotNil(t, result)
- sets := cache.snapshotSets()
- require.Len(t, sets, 1, "应恰好缓存一个 reasoning item")
- for itemID, content := range sets {
- require.NotEmpty(t, itemID)
- require.Equal(t, "think first", content)
- }
- }
- // 请求侧:encrypted-only reasoning item(无明文 summary)经缓存回查补回
- // reasoning_content;带明文 summary 的 item 顺手回写缓存(自愈)。
- func TestForwardResponses_ChatFallbackRestoresReasoningFromCache(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{
- "model":"deepseek-reasoner",
- "stream":false,
- "input":[
- {"type":"reasoning","id":"item_plain","summary":[{"type":"summary_text","text":"plain thinking"}]},
- {"type":"function_call","call_id":"call_0","name":"get_value","arguments":"{}"},
- {"type":"function_call_output","call_id":"call_0","output":"ok"},
- {"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
- {"type":"function_call","call_id":"call_1","name":"get_value","arguments":"{}"},
- {"type":"function_call_output","call_id":"call_1","output":"ok"},
- {"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
- ]
- }`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_reasoning_cache_restore"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"chatcmpl_restore","object":"chat.completion","model":"deepseek-reasoner","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`,
- )),
- }}
- cache := &reasoningRecordingCache{
- getResp: map[string]string{"item_enc1": "cached thinking"},
- }
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- cache: cache,
- }
- result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
- require.NoError(t, err)
- require.NotNil(t, result)
- // 明文 summary 的 assistant 工具调用消息:reasoning_content 来自 summary 本身。
- require.Equal(t, "plain thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
- require.Equal(t, "call_0", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String())
- require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.1.role").String())
- // encrypted-only 的 assistant 工具调用消息:reasoning_content 来自缓存回查。
- require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.2.reasoning_content").String())
- require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.2.tool_calls.0.id").String())
- require.Equal(t, "tool", gjson.GetBytes(upstream.lastBody, "messages.3.role").String())
- // 明文 summary 的 item 被回写进缓存(自愈)。
- require.Equal(t, "plain thinking", cache.snapshotSets()["item_plain"])
- }
|